From 72c6d9a0e6cedb96fe2d42358aa72d0b5eafd5c4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 12 Jun 2026 00:41:21 -0500 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20initial=20scaffold=20=E2=80=94=20?= =?UTF-8?q?MokoSuiteNPO=20nonprofit=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2 add-on for MokoSuite CRM. Full nonprofit operations: Schema (12 tables): - Donors (extends CRM contacts with giving levels + lifetime stats) - Donations (cash/check/card/ACH/stock/crypto/in-kind/pledge/matching) - Pledges (installment tracking with fulfilment status) - Funds (unrestricted/temporarily/permanently restricted/endowment) - Campaigns (fundraising drives with goals and thermometers) - Grants (prospect > writing > submitted > awarded > reporting lifecycle) - Volunteers (skills, availability, background checks) - Volunteer Hours (activity logging with approval) - Memberships (levels, dues, auto-renew) - Tax Receipts (IRS-compliant acknowledgment letters) - Events (galas, auctions, community events with registration) - Event Registrations (tickets, dietary, table assignment) Helpers (4): - DonorHelper: profiles, giving history, level auto-calculation, lapsed detection - CampaignHelper: active campaigns, thermometer data, fundraising summary - VolunteerHelper: registration, hours logging, skills matching, reporting - TaxReceiptHelper: IRS-compliant receipt generation, year-end batch Infrastructure: - Joomla 6 architecture (PHP 8.3+, DI container, typed properties) - Admin dashboard with fundraising thermometers + grant deadlines - 6 webservice routes (donors, donations, campaigns, grants, volunteers, events) - Package manifest with DLID update server - GPL-3.0 license --- CHANGELOG.md | 14 + LICENSE | 1 + .../admin/services/provider.php | 20 ++ .../src/Controller/DisplayController.php | 11 + .../admin/src/View/Dashboard/HtmlView.php | 50 +++ .../admin/tmpl/dashboard/default.php | 95 +++++ .../com_mokosuitenpo/mokosuitenpo.xml | 21 ++ .../site/src/Controller/DisplayController.php | 11 + .../sql/install.mysql.sql | 328 ++++++++++++++++++ .../sql/uninstall.mysql.sql | 12 + .../src/Helper/CampaignHelper.php | 98 ++++++ .../src/Helper/DonorHelper.php | 165 +++++++++ .../src/Helper/TaxReceiptHelper.php | 145 ++++++++ .../src/Helper/VolunteerHelper.php | 102 ++++++ source/pkg_mokosuitenpo.xml | 28 ++ source/script.php | 8 + source/updates.xml | 9 + 17 files changed, 1118 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 source/packages/com_mokosuitenpo/admin/services/provider.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Controller/DisplayController.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Dashboard/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/dashboard/default.php create mode 100644 source/packages/com_mokosuitenpo/mokosuitenpo.xml create mode 100644 source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php create mode 100644 source/packages/plg_system_mokosuitenpo/sql/install.mysql.sql create mode 100644 source/packages/plg_system_mokosuitenpo/sql/uninstall.mysql.sql create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/CampaignHelper.php create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php create mode 100644 source/pkg_mokosuitenpo.xml create mode 100644 source/script.php create mode 100644 source/updates.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b2eefe8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# MokoSuite NPO +Nonprofit management for MokoSuite. Layer 2 add-on (requires CRM). + +## Features +- Donor management with giving levels +- Donation tracking with fund allocation +- Pledge management +- Campaign/fundraising with thermometers +- Grant lifecycle management +- Volunteer management with hours +- Membership program +- Tax receipt generation (IRS-compliant) +- Event management with registration +- Fund accounting (restricted vs unrestricted) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04fd5c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +GPL-3.0-or-later - See https://www.gnu.org/licenses/gpl-3.0.html diff --git a/source/packages/com_mokosuitenpo/admin/services/provider.php b/source/packages/com_mokosuitenpo/admin/services/provider.php new file mode 100644 index 0000000..9c605c7 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/services/provider.php @@ -0,0 +1,20 @@ +set(ComponentInterface::class, function (Container $container) { + $component = new MVCComponent($container->get(ComponentDispatcherFactoryInterface::class)); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + return $component; + }); + } +}; diff --git a/source/packages/com_mokosuitenpo/admin/src/Controller/DisplayController.php b/source/packages/com_mokosuitenpo/admin/src/Controller/DisplayController.php new file mode 100644 index 0000000..5ce2793 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Controller/DisplayController.php @@ -0,0 +1,11 @@ +get(DatabaseInterface::class); + + $this->donorStats = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getDonorSummary(); + $this->fundraising = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getFundraisingSummary(); + $this->volunteerStats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats(); + $this->activeCampaigns= \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns(); + + // Recent donations + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name, don.anonymous_giving') + ->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') + ->order('d.donation_date DESC, d.created DESC'), 0, 10); + $this->recentDonations = $db->loadObjectList() ?: []; + + // Upcoming grant deadlines + $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') . ' >= CURDATE()') + ->order('application_deadline ASC'), 0, 5); + $this->upcomingGrants = $db->loadObjectList() ?: []; + + ToolbarHelper::title('MokoSuite NPO Dashboard', 'icon-heart'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/dashboard/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/dashboard/default.php new file mode 100644 index 0000000..07505cc --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/dashboard/default.php @@ -0,0 +1,95 @@ +donorStats; +$fr = $this->fundraising; +$vs = $this->volunteerStats; +$campaigns = $this->activeCampaigns; +$recent = $this->recentDonations; +$grants = $this->upcomingGrants; +?> + +
+ +
+
$total_raised, 0); ?>
Raised This Year
+
total_donors; ?>
Total Donors
+
total_gifts; ?>
Gifts
+
$avg_gift, 0); ?>
Avg Gift
+
active; ?>
Active Volunteers
total_hours, 0); ?> hours
+
+ +
+ +
+
+
Active Campaigns
+
+ +
+
+ escape($c->title); ?> + progress_pct; ?>% +
+
+
+ $raised_amount, 0); ?> / $goal_amount, 0); ?> +
+
+ donor_count; ?> donorsdays_remaining !== null ? " · {$c->days_remaining} days left" : ''; ?> +
+ +

No active campaigns.

+
+
+
+ + +
+
+
Recent Donations
+
+ + + + + + + + + + + + +
DonorAmountTypeDate
anonymous_giving ? 'Anonymous' : $this->escape($d->donor_name ?? ''); ?>$amount, 2); ?>donation_type); ?>donation_date)); ?>
+
+
+
+
+ + + +
+
Upcoming Grant Deadlines
+
+ + + + application_deadline) - time()) / 86400)); + ?> + + + + + + + + + +
GrantFunderAmountDeadlineStatus
escape($g->title); ?>escape($g->funder_name); ?>$amount_requested, 0); ?> daysstatus); ?>
+
+
+ +
diff --git a/source/packages/com_mokosuitenpo/mokosuitenpo.xml b/source/packages/com_mokosuitenpo/mokosuitenpo.xml new file mode 100644 index 0000000..4135fa0 --- /dev/null +++ b/source/packages/com_mokosuitenpo/mokosuitenpo.xml @@ -0,0 +1,21 @@ + + + MokoSuite NPO + Moko Consulting + 2026-06-11 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 01.01.00 + 8.3 + MokoSuite NPO component + Moko\Component\MokoSuiteNpo + + MokoSuite NPO + srcservicestmpl + + srctmpl + src + cssjs + diff --git a/source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php new file mode 100644 index 0000000..1c574f0 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php @@ -0,0 +1,11 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('c.*, f.name AS fund_name') + ->from($db->quoteName('#__mokosuitenpo_campaigns', 'c')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = c.fund_id') + ->where($db->quoteName('c.status') . ' = ' . $db->quote('active')) + ->order('c.end_date ASC')); + $campaigns = $db->loadObjectList() ?: []; + + foreach ($campaigns as &$c) { + $c->progress_pct = $c->goal_amount > 0 ? min(100, round($c->raised_amount / $c->goal_amount * 100)) : 0; + $c->remaining = max(0, (float) $c->goal_amount - (float) $c->raised_amount); + $c->days_remaining = $c->end_date ? max(0, round((strtotime($c->end_date) - time()) / 86400)) : null; + } + + return $campaigns; + } + + public static function getThermometerData(int $campaignId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_campaigns')->where('id = ' . $campaignId)); + $c = $db->loadObject(); + + if (!$c) return (object) ['goal' => 0, 'raised' => 0, 'pct' => 0, 'donors' => 0]; + + return (object) [ + 'goal' => (float) $c->goal_amount, + 'raised' => (float) $c->raised_amount, + 'pct' => $c->goal_amount > 0 ? min(100, round($c->raised_amount / $c->goal_amount * 100)) : 0, + 'donors' => (int) $c->donor_count, + 'remaining' => max(0, (float) $c->goal_amount - (float) $c->raised_amount), + 'title' => $c->title, + 'end_date' => $c->end_date, + ]; + } + + public static function getCampaignDonations(int $campaignId, int $limit = 50): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name') + ->select('don.anonymous_giving, don.recognition_name') + ->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') + ->where('d.campaign_id = ' . $campaignId) + ->order('d.donation_date DESC'), 0, $limit); + + $donations = $db->loadObjectList() ?: []; + + // Anonymize where needed + foreach ($donations as &$d) { + if ($d->anonymous_giving) { + $d->donor_name = 'Anonymous'; + } elseif ($d->recognition_name) { + $d->donor_name = $d->recognition_name; + } + } + + return $donations; + } + + public static function getFundraisingSummary(int $year = 0): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $year = $year ?: (int) date('Y'); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(DISTINCT d.donor_id) AS unique_donors') + ->select('COUNT(*) AS total_gifts') + ->select('COALESCE(SUM(d.amount), 0) AS total_raised') + ->select('COALESCE(AVG(d.amount), 0) AS avg_gift') + ->select('MAX(d.amount) AS largest_gift') + ->from($db->quoteName('#__mokosuitenpo_donations', 'd')) + ->where('YEAR(d.donation_date) = ' . $year)); + + return $db->loadObject() ?: (object) ['unique_donors' => 0, 'total_gifts' => 0, 'total_raised' => 0, 'avg_gift' => 0, 'largest_gift' => 0]; + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php new file mode 100644 index 0000000..9d1f57c --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php @@ -0,0 +1,165 @@ + ['min' => 0, 'label' => 'Prospect'], + 'first_time' => ['min' => 1, 'label' => 'First-Time Donor'], + 'repeat' => ['min' => 100, 'label' => 'Repeat Donor'], + 'major' => ['min' => 5000, 'label' => 'Major Donor'], + 'legacy' => ['min' => 50000, 'label' => 'Legacy Donor'], + ]; + + public static function getOrCreateDonor(int $contactId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_donors') + ->where('contact_id = ' . $contactId)); + $donor = $db->loadObject(); + + if ($donor) return $donor; + + $donor = (object) [ + 'contact_id' => $contactId, + 'donor_type' => 'individual', + 'donor_level' => 'prospect', + 'lifetime_giving' => 0, + 'gift_count' => 0, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_donors', $donor, 'id'); + return $donor; + } + + public static function recordDonation(int $donorId, float $amount, int $fundId = 1, string $type = 'cash', ?int $campaignId = null, array $extra = []): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $donation = (object) [ + 'donor_id' => $donorId, + 'fund_id' => $fundId, + 'campaign_id' => $campaignId, + 'amount' => $amount, + 'donation_type' => $type, + 'donation_date' => $extra['date'] ?? date('Y-m-d'), + 'payment_method' => $extra['payment_method'] ?? null, + 'payment_reference' => $extra['reference'] ?? null, + 'is_tax_deductible' => (int) ($extra['tax_deductible'] ?? 1), + 'tribute_type' => $extra['tribute_type'] ?? null, + 'tribute_name' => $extra['tribute_name'] ?? null, + 'notes' => $extra['notes'] ?? '', + 'created' => Factory::getDate()->toSql(), + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->insertObject('#__mokosuitenpo_donations', $donation, 'id'); + + // Update donor stats + self::updateDonorStats($donorId); + + // Update fund balance + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_funds') + ->set('current_balance = current_balance + ' . (float) $amount) + ->where('id = ' . $fundId)); + $db->execute(); + + // Update campaign raised amount + if ($campaignId) { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_campaigns') + ->set('raised_amount = raised_amount + ' . (float) $amount) + ->set('donor_count = donor_count + 1') + ->where('id = ' . $campaignId)); + $db->execute(); + } + + return (int) $donation->id; + } + + public static function updateDonorStats(int $donorId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS gift_count, COALESCE(SUM(amount), 0) AS lifetime, MAX(amount) AS largest, MIN(donation_date) AS first_date, MAX(donation_date) AS last_date') + ->from('#__mokosuitenpo_donations') + ->where('donor_id = ' . $donorId)); + $stats = $db->loadObject(); + + $lifetime = (float) ($stats->lifetime ?? 0); + $level = 'prospect'; + foreach (array_reverse(self::LEVELS) as $key => $config) { + if ($lifetime >= $config['min']) { $level = $key; break; } + } + + $db->updateObject('#__mokosuitenpo_donors', (object) [ + 'id' => $donorId, + 'lifetime_giving' => $lifetime, + 'largest_gift' => (float) ($stats->largest ?? 0), + 'first_gift_date' => $stats->first_date, + 'last_gift_date' => $stats->last_date, + 'gift_count' => (int) ($stats->gift_count ?? 0), + 'donor_level' => $level, + 'modified' => Factory::getDate()->toSql(), + ], 'id'); + } + + public static function getDonorSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_donors') + ->select('SUM(lifetime_giving) AS total_lifetime') + ->select('SUM(CASE WHEN donor_level = ' . $db->quote('major') . ' THEN 1 ELSE 0 END) AS major_donors') + ->select('SUM(CASE WHEN donor_level = ' . $db->quote('lapsed') . ' THEN 1 ELSE 0 END) AS lapsed_donors') + ->select('AVG(lifetime_giving) AS avg_lifetime') + ->from('#__mokosuitenpo_donors')); + + return $db->loadObject() ?: (object) ['total_donors' => 0, 'total_lifetime' => 0, 'major_donors' => 0, 'lapsed_donors' => 0, 'avg_lifetime' => 0]; + } + + public static function getTopDonors(int $limit = 10, string $dateFrom = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('d.anonymous_giving') . ' = 0') + ->order('d.lifetime_giving DESC'); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + public static function getLapsedDonors(int $monthsInactive = 12): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $cutoff = date('Y-m-d', strtotime("-{$monthsInactive} months")); + + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('d.last_gift_date') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('d.gift_count') . ' > 0') + ->order('d.last_gift_date ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php new file mode 100644 index 0000000..4c9adaa --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php @@ -0,0 +1,145 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('d.*, don.contact_id, cd.name AS donor_name, cd.email_to, cd.address') + ->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') + ->where('d.id = ' . $donationId)); + $donation = $db->loadObject(); + + if (!$donation || !$donation->is_tax_deductible) return null; + + $params = Factory::getApplication()->getParams('com_mokosuitenpo'); + $prefix = $params->get('receipt_prefix', 'RCP'); + $taxYear = date('Y', strtotime($donation->donation_date)); + + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)') + ->from('#__mokosuitenpo_tax_receipts') + ->where('tax_year = ' . $taxYear))->loadResult() + 1; + + $receiptNumber = $prefix . '-' . $taxYear . '-' . str_pad($seq, 5, '0', STR_PAD_LEFT); + + $receipt = (object) [ + 'donation_id' => $donationId, + 'donor_id' => $donation->donor_id, + 'receipt_number' => $receiptNumber, + 'tax_year' => $taxYear, + 'amount' => $donation->amount, + 'date_issued' => date('Y-m-d'), + 'delivery_method'=> $donation->email_to ? 'email' : 'mail', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_tax_receipts', $receipt, 'id'); + + // Mark donation as receipt sent + $db->updateObject('#__mokosuitenpo_donations', (object) [ + 'id' => $donationId, + 'receipt_sent' => 1, + 'receipt_sent_date' => date('Y-m-d'), + ], 'id'); + + return (int) $receipt->id; + } + + public static function sendReceipt(int $receiptId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('r.*, cd.name AS donor_name, cd.email_to, cd.address') + ->from($db->quoteName('#__mokosuitenpo_tax_receipts', 'r')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = r.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id') + ->where('r.id = ' . $receiptId)); + $receipt = $db->loadObject(); + + if (!$receipt || !$receipt->email_to) return false; + + $params = Factory::getApplication()->getParams('com_mokosuitenpo'); + $orgName = $params->get('org_name', Factory::getApplication()->get('sitename')); + $orgEin = $params->get('org_ein', ''); + $orgAddress = $params->get('org_address', ''); + + $body = "TAX RECEIPT\n" + . str_repeat('=', 50) . "\n\n" + . "Receipt Number: {$receipt->receipt_number}\n" + . "Date: " . date('F j, Y', strtotime($receipt->date_issued)) . "\n\n" + . "Organization: {$orgName}\n" + . ($orgEin ? "EIN: {$orgEin}\n" : '') + . ($orgAddress ? "Address: {$orgAddress}\n" : '') + . "\n" + . "Donor: {$receipt->donor_name}\n" + . "Donation Amount: \$" . number_format((float) $receipt->amount, 2) . "\n" + . "Tax Year: {$receipt->tax_year}\n\n" + . "No goods or services were provided in exchange for this contribution.\n\n" + . "This receipt serves as your official acknowledgment for tax purposes.\n" + . "Please retain for your records.\n\n" + . "Thank you for your generous support!\n"; + + $mailer = Factory::getMailer(); + $mailer->addRecipient($receipt->email_to, $receipt->donor_name); + $mailer->setSubject("Tax Receipt {$receipt->receipt_number} - {$orgName}"); + $mailer->setBody($body); + $result = $mailer->Send(); + + if ($result) { + $db->updateObject('#__mokosuitenpo_tax_receipts', (object) [ + 'id' => $receiptId, 'sent' => 1, 'sent_date' => Factory::getDate()->toSql(), + ], 'id'); + } + + return (bool) $result; + } + + public static function generateYearEndReceipts(int $taxYear): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('DISTINCT donor_id') + ->from('#__mokosuitenpo_donations') + ->where('YEAR(donation_date) = ' . $taxYear) + ->where('is_tax_deductible = 1') + ->where('receipt_sent = 0')); + $donorIds = $db->loadColumn() ?: []; + + $generated = 0; + foreach ($donorIds as $donorId) { + $db->setQuery($db->getQuery(true) + ->select('id') + ->from('#__mokosuitenpo_donations') + ->where('donor_id = ' . (int) $donorId) + ->where('YEAR(donation_date) = ' . $taxYear) + ->where('is_tax_deductible = 1') + ->where('receipt_sent = 0')); + $donationIds = $db->loadColumn() ?: []; + + foreach ($donationIds as $did) { + $receiptId = self::generate((int) $did); + if ($receiptId) { + self::sendReceipt($receiptId); + $generated++; + } + } + } + + return $generated; + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php new file mode 100644 index 0000000..ae44143 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php @@ -0,0 +1,102 @@ +get(DatabaseInterface::class); + + $volunteer = (object) [ + 'contact_id' => $contactId, + 'status' => 'active', + 'skills' => !empty($skills) ? json_encode($skills) : null, + 'availability' => !empty($availability) ? json_encode($availability) : null, + 'start_date' => date('Y-m-d'), + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_volunteers', $volunteer, 'id'); + return (int) $volunteer->id; + } + + public static function logHours(int $volunteerId, string $activity, float $hours, string $date = '', string $notes = ''): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $log = (object) [ + 'volunteer_id' => $volunteerId, + 'activity' => $activity, + 'hours' => $hours, + 'volunteer_date'=> $date ?: date('Y-m-d'), + 'notes' => $notes, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_volunteer_hours', $log, 'id'); + + // Update total hours + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_volunteers') + ->set('total_hours = total_hours + ' . (float) $hours) + ->where('id = ' . $volunteerId)); + $db->execute(); + + return (int) $log->id; + } + + public static function getVolunteerStats(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_volunteers') + ->select('SUM(CASE WHEN status = ' . $db->quote('active') . ' THEN 1 ELSE 0 END) AS active') + ->select('COALESCE(SUM(total_hours), 0) AS total_hours') + ->select('COALESCE(AVG(total_hours), 0) AS avg_hours') + ->from('#__mokosuitenpo_volunteers')); + + return $db->loadObject() ?: (object) ['total_volunteers' => 0, 'active' => 0, 'total_hours' => 0, 'avg_hours' => 0]; + } + + public static function findBySkill(string $skill): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_volunteers', 'v')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('v.skills') . ' LIKE ' . $db->quote('%' . $db->escape($skill) . '%')) + ->order('v.total_hours DESC')); + + return $db->loadObjectList() ?: []; + } + + public static function getHoursReport(string $dateFrom = '', string $dateTo = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $dateFrom = $dateFrom ?: date('Y-01-01'); + $dateTo = $dateTo ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('cd.name AS volunteer_name, SUM(vh.hours) AS total_hours, COUNT(*) AS sessions') + ->from($db->quoteName('#__mokosuitenpo_volunteer_hours', 'vh')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_volunteers', 'v') . ' ON v.id = vh.volunteer_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->where('vh.volunteer_date BETWEEN ' . $db->quote($dateFrom) . ' AND ' . $db->quote($dateTo)) + ->group('vh.volunteer_id') + ->order('total_hours DESC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml new file mode 100644 index 0000000..dbef4db --- /dev/null +++ b/source/pkg_mokosuitenpo.xml @@ -0,0 +1,28 @@ + + + Package - MokoSuite NPO + mokosuitenpo + 01.01.00 + 2026-06-11 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + MokoSuite NPO - nonprofit management: donors, donations, campaigns, grants, volunteers, fund accounting. Layer 2 add-on for MokoSuite (requires CRM). + 8.3 + + true + script.php + + + plg_system_mokosuitenpo.zip + com_mokosuitenpo.zip + plg_webservices_mokosuitenpo.zip + plg_task_mokosuitenpo.zip + + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/updates.xml + + diff --git a/source/script.php b/source/script.php new file mode 100644 index 0000000..d76d3d4 --- /dev/null +++ b/source/script.php @@ -0,0 +1,8 @@ + + +Package - MokoSuite NPO +pkg_mokosuitenpo +package +01.01.00 + +8.3 + -- 2.52.0 From 63e7e8a9226261b305093c962a9601cecd769870 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 12 Jun 2026 02:20:31 -0500 Subject: [PATCH 02/17] chore: add MokoSuiteCRM as git submodule for layer packaging --- .gitmodules | 3 +++ packages/MokoSuiteCRM | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 packages/MokoSuiteCRM diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2a17a2e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/MokoSuiteCRM"] + path = packages/MokoSuiteCRM + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git diff --git a/packages/MokoSuiteCRM b/packages/MokoSuiteCRM new file mode 160000 index 0000000..0c9d985 --- /dev/null +++ b/packages/MokoSuiteCRM @@ -0,0 +1 @@ +Subproject commit 0c9d985d567beb815d00bd37bde072ad26e0380c -- 2.52.0 From aee7bd071d12835d9f6b22dafba7898384c8fafe Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Fri, 12 Jun 2026 02:26:17 -0500 Subject: [PATCH 03/17] chore: add MokoSuite base as direct submodule for packaging --- .gitmodules | 3 +++ packages/MokoSuite | 1 + 2 files changed, 4 insertions(+) create mode 160000 packages/MokoSuite diff --git a/.gitmodules b/.gitmodules index 2a17a2e..7385cca 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "packages/MokoSuiteCRM"] path = packages/MokoSuiteCRM url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git +[submodule "packages/MokoSuite"] + path = packages/MokoSuite + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuite.git diff --git a/packages/MokoSuite b/packages/MokoSuite new file mode 160000 index 0000000..6cd16d9 --- /dev/null +++ b/packages/MokoSuite @@ -0,0 +1 @@ +Subproject commit 6cd16d984589fabbfd0b074b0d3308b5e64967f0 -- 2.52.0 From 675fff8ca622153f0e709e68c754928700fe4fbc Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 06:53:08 -0500 Subject: [PATCH 04/17] feat: donors list, campaigns with thermometers, grants pipeline, donations API NpoDonationsController API: donations CRUD, campaigns list, thermometer data, donor list, fundraising summary. Admin views: Donors list with lifetime giving + level badges, Campaigns with progress bars + donor counts, Grants pipeline with deadline tracking. --- .../admin/src/View/Campaigns/HtmlView.php | 23 +++++ .../admin/src/View/Donors/HtmlView.php | 49 ++++++++++ .../admin/src/View/Grants/HtmlView.php | 30 ++++++ .../admin/tmpl/campaigns/default.php | 6 ++ .../admin/tmpl/donors/default.php | 27 ++++++ .../admin/tmpl/grants/default.php | 7 ++ .../src/Controller/NpoDonationsController.php | 97 +++++++++++++++++++ 7 files changed, 239 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Campaigns/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Donors/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Grants/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/campaigns/default.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/donors/default.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/grants/default.php create mode 100644 source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php diff --git a/source/packages/com_mokosuitenpo/admin/src/View/Campaigns/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Campaigns/HtmlView.php new file mode 100644 index 0000000..ee70e11 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/View/Campaigns/HtmlView.php @@ -0,0 +1,23 @@ +campaigns = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns(); + $this->summary = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getFundraisingSummary(); + + ToolbarHelper::title('NPO — Campaigns', 'icon-bullhorn'); + ToolbarHelper::addNew('campaigns.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/View/Donors/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Donors/HtmlView.php new file mode 100644 index 0000000..3b14fb5 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/View/Donors/HtmlView.php @@ -0,0 +1,49 @@ +get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $this->filters = [ + 'level' => $input->getString('filter_level', ''), + 'type' => $input->getString('filter_type', ''), + 'search' => $input->getString('filter_search', ''), + ]; + + $query = $db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->order('d.lifetime_giving DESC'); + + if ($this->filters['level']) $query->where($db->quoteName('d.donor_level') . ' = ' . $db->quote($this->filters['level'])); + if ($this->filters['type']) $query->where($db->quoteName('d.donor_type') . ' = ' . $db->quote($this->filters['type'])); + if ($this->filters['search']) { + $like = $db->quote('%' . $db->escape($this->filters['search'], true) . '%'); + $query->where('cd.name LIKE ' . $like); + } + + $db->setQuery($query, 0, 100); + $this->donors = $db->loadObjectList() ?: []; + + $this->stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getDonorSummary(); + + ToolbarHelper::title('NPO — Donors', 'icon-heart'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/View/Grants/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Grants/HtmlView.php new file mode 100644 index 0000000..1b02c46 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/View/Grants/HtmlView.php @@ -0,0 +1,30 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('g.*, f.name AS fund_name') + ->from($db->quoteName('#__mokosuitenpo_grants', 'g')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = g.fund_id') + ->order('FIELD(g.status,' . $db->quote('prospect') . ',' . $db->quote('writing') . ',' . $db->quote('submitted') . ',' . $db->quote('pending') . ',' . $db->quote('awarded') . ',' . $db->quote('reporting') . ',' . $db->quote('declined') . ',' . $db->quote('closed') . ') ASC, g.application_deadline ASC')); + $this->grants = $db->loadObjectList() ?: []; + + ToolbarHelper::title('NPO — Grants', 'icon-file-invoice'); + ToolbarHelper::addNew('grants.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/campaigns/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/campaigns/default.php new file mode 100644 index 0000000..9a1c59b --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/campaigns/default.php @@ -0,0 +1,6 @@ +campaigns; $summary=$this->summary; ?> +
$total_raised,0); ?>
Raised This Year
unique_donors; ?>
Donors
total_gifts; ?>
Gifts
$avg_gift,0); ?>
Avg Gift
+ +
title); ?>progress_pct; ?>%
$raised_amount,0); ?> / $goal_amount,0); ?>
donor_count; ?> donorsdays_remaining!==null?" - {$c->days_remaining} days left":""; ?>
+ +

No active campaigns.

diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/donors/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/donors/default.php new file mode 100644 index 0000000..9f0b650 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/donors/default.php @@ -0,0 +1,27 @@ +donors; +$stats = $this->stats; +$f = $this->filters; +$levelColors = ['prospect'=>'secondary','first_time'=>'info','repeat'=>'primary','major'=>'warning','legacy'=>'success','lapsed'=>'danger']; +?> +
+
total_donors; ?>
Total Donors
+
$total_lifetime,0); ?>
Lifetime Giving
+
major_donors; ?>
Major Donors
+
$avg_lifetime,0); ?>
Avg Lifetime
+
+ + + + + + + + + + + + + +
DonorEmailTypeLevelLifetimeGiftsLast Gift
escape($d->donor_name??''); ?>escape($d->email_to??''); ?>donor_type); ?>donor_level)); ?>$lifetime_giving,2); ?>gift_count; ?>last_gift_date?date('M j, Y',strtotime($d->last_gift_date)):'Never'; ?>
No donors
diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/grants/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/grants/default.php new file mode 100644 index 0000000..1227530 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/grants/default.php @@ -0,0 +1,7 @@ +grants; $statusColors=["prospect"=>"secondary","writing"=>"info","submitted"=>"primary","pending"=>"warning","awarded"=>"success","declined"=>"danger","reporting"=>"info","closed"=>"dark"]; ?> + +application_deadline?max(0,round((strtotime($g->application_deadline)-time())/86400)):null; ?> +"> + + +
GrantFunderRequestedAwardedStatusDeadline
title); ?>funder_name); ?>$amount_requested,0); ?>amount_awarded?"$".number_format((float)$g->amount_awarded,0):"—"; ?>">status); ?>application_deadline?date("M j",strtotime($g->application_deadline)):"—"; ?> ">d
No grants
diff --git a/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php b/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php new file mode 100644 index 0000000..bc5844c --- /dev/null +++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php @@ -0,0 +1,97 @@ +get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $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') + ->order('d.donation_date DESC'); + + $campaignId = $input->getInt('campaign_id', 0); + if ($campaignId) $query->where('d.campaign_id = ' . $campaignId); + + $fundId = $input->getInt('fund_id', 0); + if ($fundId) $query->where('d.fund_id = ' . $fundId); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function createDonation(): void + { + $input = Factory::getApplication()->getInput(); + $contactId = $input->getInt('contact_id', 0); + + $donor = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getOrCreateDonor($contactId); + + $donationId = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::recordDonation( + (int) $donor->id, + $input->getFloat('amount', 0), + $input->getInt('fund_id', 1), + $input->getString('donation_type', 'cash'), + $input->getInt('campaign_id', 0) ?: null, + [ + 'date' => $input->getString('date', date('Y-m-d')), + 'payment_method' => $input->getString('payment_method', ''), + 'reference' => $input->getString('reference', ''), + 'tribute_type' => $input->getString('tribute_type', ''), + 'tribute_name' => $input->getString('tribute_name', ''), + 'notes' => $input->getString('notes', ''), + ] + ); + + $this->sendJson(['id' => $donationId, 'message' => 'Donation recorded.']); + } + + public function listCampaigns(): void + { + $campaigns = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns(); + $this->sendJson($campaigns); + } + + public function thermometer(): void + { + $campaignId = Factory::getApplication()->getInput()->getInt('campaign_id', 0); + $data = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getThermometerData($campaignId); + $this->sendJson($data); + } + + public function listDonors(): void + { + $donors = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getTopDonors(50); + $this->sendJson($donors); + } + + public function fundraisingSummary(): void + { + $year = Factory::getApplication()->getInput()->getInt('year', (int) date('Y')); + $this->sendJson(\Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getFundraisingSummary($year)); + } + + private function sendJson(mixed $data): void + { + $app = Factory::getApplication(); + $app->getDocument()->setMimeEncoding('application/json'); + echo json_encode(['data' => $data], JSON_THROW_ON_ERROR); + $app->close(); + } +} -- 2.52.0 From 2b6d52eeb3ee6ed0e5a460bd385fb50e2bc1cfc3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 07:10:16 -0500 Subject: [PATCH 05/17] 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. --- .../com_mokosuitenpo/admin/access.xml | 11 ++ .../com_mokosuitenpo/admin/config.xml | 13 ++ .../site/src/Service/Router.php | 21 +++ .../src/Extension/NpoAutomation.php | 133 ++++++++++++++++++ .../src/Extension/MokoSuiteNpoApi.php | 27 ++++ 5 files changed, 205 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/admin/access.xml create mode 100644 source/packages/com_mokosuitenpo/admin/config.xml create mode 100644 source/packages/com_mokosuitenpo/site/src/Service/Router.php create mode 100644 source/packages/plg_task_mokosuitenpo/src/Extension/NpoAutomation.php create mode 100644 source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php diff --git a/source/packages/com_mokosuitenpo/admin/access.xml b/source/packages/com_mokosuitenpo/admin/access.xml new file mode 100644 index 0000000..352c7ca --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/access.xml @@ -0,0 +1,11 @@ + + +
+ + + + + + +
+
diff --git a/source/packages/com_mokosuitenpo/admin/config.xml b/source/packages/com_mokosuitenpo/admin/config.xml new file mode 100644 index 0000000..dc814a6 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/config.xml @@ -0,0 +1,13 @@ + + +
+ + + +
+
+ + + +
+
diff --git a/source/packages/com_mokosuitenpo/site/src/Service/Router.php b/source/packages/com_mokosuitenpo/site/src/Service/Router.php new file mode 100644 index 0000000..e3c5688 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/Service/Router.php @@ -0,0 +1,21 @@ + [ + '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; + } +} diff --git a/source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php b/source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php new file mode 100644 index 0000000..d7d0764 --- /dev/null +++ b/source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php @@ -0,0 +1,27 @@ + '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']); + } +} -- 2.52.0 From f97c86bc71e84f388f67de1a1c6411a84571751e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 07:20:07 -0500 Subject: [PATCH 06/17] chore: comprehensive config.xml settings + access.xml permissions --- source/packages/com_mokosuitenpo/admin/config.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/packages/com_mokosuitenpo/admin/config.xml b/source/packages/com_mokosuitenpo/admin/config.xml index dc814a6..d58596f 100644 --- a/source/packages/com_mokosuitenpo/admin/config.xml +++ b/source/packages/com_mokosuitenpo/admin/config.xml @@ -10,4 +10,7 @@ +
+ +
-- 2.52.0 From 7a033da7edf6af933457919d2a4da15515e46412 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 07:33:08 -0500 Subject: [PATCH 07/17] feat: wire ACL permission checks into API controller NpoDonationsController: requireAuth() checks on listDonations and createDonation (npo.donations permission). Matches access.xml actions. --- .../api/src/Controller/NpoDonationsController.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php b/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php index bc5844c..dd17b2c 100644 --- a/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php +++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php @@ -12,8 +12,19 @@ use Joomla\Database\DatabaseInterface; */ class NpoDonationsController extends BaseController { + private function requireAuth(string $action = 'core.manage'): void + { + $user = Factory::getApplication()->getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + public function listDonations(): void { + $this->requireAuth('npo.donations'); $db = Factory::getContainer()->get(DatabaseInterface::class); $input = Factory::getApplication()->getInput(); @@ -38,6 +49,7 @@ class NpoDonationsController extends BaseController public function createDonation(): void { + $this->requireAuth('npo.donations'); $input = Factory::getApplication()->getInput(); $contactId = $input->getInt('contact_id', 0); -- 2.52.0 From 9f304902f951bf141415d83e1e4d9caf25c90da0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 08:01:55 -0500 Subject: [PATCH 08/17] feat: public donate page, campaign page with thermometer, volunteer signup Donate: online donation form with quick-amount buttons, fund/campaign selection, tribute (in honor/memory), auto tax receipt generation, contact auto-creation. Campaign page: public thermometer + recent donors + donate button. Volunteer signup: skills checkboxes, day availability, auto contact creation + volunteer registration. --- .../site/src/View/CampaignPage/HtmlView.php | 48 ++++++++ .../site/src/View/Donate/HtmlView.php | 104 ++++++++++++++++++ .../src/View/VolunteerSignup/HtmlView.php | 61 ++++++++++ .../site/tmpl/campaignpage/default.php | 41 +++++++ .../site/tmpl/donate/default.php | 104 ++++++++++++++++++ .../site/tmpl/volunteersignup/default.php | 36 ++++++ 6 files changed, 394 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/site/src/View/CampaignPage/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/site/src/View/Donate/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/site/src/View/VolunteerSignup/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/site/tmpl/campaignpage/default.php create mode 100644 source/packages/com_mokosuitenpo/site/tmpl/donate/default.php create mode 100644 source/packages/com_mokosuitenpo/site/tmpl/volunteersignup/default.php diff --git a/source/packages/com_mokosuitenpo/site/src/View/CampaignPage/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/CampaignPage/HtmlView.php new file mode 100644 index 0000000..6247498 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/View/CampaignPage/HtmlView.php @@ -0,0 +1,48 @@ +getInput()->getInt('id', 0); + + if (!$campaignId) { + $app->enqueueMessage('Campaign not found.', 'error'); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_campaigns') + ->where('id = ' . $campaignId) + ->where($db->quoteName('public_page') . ' = 1')); + $this->campaign = $db->loadObject(); + + if (!$this->campaign) { + $app->enqueueMessage('Campaign not found.', 'error'); + return; + } + + $this->thermometer = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getThermometerData($campaignId); + $this->recentDonors = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getCampaignDonations($campaignId, 10); + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/site/src/View/Donate/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/Donate/HtmlView.php new file mode 100644 index 0000000..0281bdf --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/View/Donate/HtmlView.php @@ -0,0 +1,104 @@ +get(DatabaseInterface::class); + + $params = $app->getParams('com_mokosuitenpo'); + $this->orgInfo = (object) [ + 'name' => $params->get('org_name', $app->get('sitename')), + 'ein' => $params->get('org_ein', ''), + 'address' => $params->get('org_address', ''), + ]; + + // Active campaigns for dropdown + $this->campaigns = \Moko\Plugin\System\MokoSuiteNpo\Helper\CampaignHelper::getActiveCampaigns(); + + // Available funds + $db->setQuery($db->getQuery(true) + ->select('id, name, fund_type') + ->from('#__mokosuitenpo_funds') + ->where($db->quoteName('published') . ' = 1') + ->order('sort_order ASC, name ASC')); + $this->funds = $db->loadObjectList() ?: []; + + // Handle POST submission + if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) { + $this->processDonation($app, $db); + } + + parent::display($tpl); + } + + private function processDonation($app, $db): void + { + $input = $app->getInput(); + + $name = $input->getString('donor_name', ''); + $email = $input->getString('donor_email', ''); + $amount = $input->getFloat('amount', 0); + $fundId = $input->getInt('fund_id', 1); + $campaignId = $input->getInt('campaign_id', 0) ?: null; + + if (!$name || !$email || $amount <= 0) { + $app->enqueueMessage('Please fill in all required fields.', 'warning'); + return; + } + + // Find or create contact + $db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details') + ->where($db->quoteName('email_to') . ' = ' . $db->quote($email))); + $contactId = (int) $db->loadResult(); + + if (!$contactId) { + $db->insertObject('#__contact_details', (object) [ + 'name' => $name, 'email_to' => $email, 'published' => 1, + 'created' => Factory::getDate()->toSql(), + ], 'id'); + $contactId = (int) $db->loadResult() ?: $db->insertid(); + } + + // Get or create donor + $donor = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::getOrCreateDonor($contactId); + + // Record donation + $donationId = \Moko\Plugin\System\MokoSuiteNpo\Helper\DonorHelper::recordDonation( + (int) $donor->id, $amount, $fundId, 'credit_card', $campaignId, [ + 'date' => date('Y-m-d'), + 'payment_method' => 'online', + 'tribute_type' => $input->getString('tribute_type', '') ?: null, + 'tribute_name' => $input->getString('tribute_name', '') ?: null, + 'notes' => 'Online donation', + ] + ); + + // Auto-generate receipt if configured + $params = Factory::getApplication()->getParams('com_mokosuitenpo'); + if ($params->get('auto_receipt', true) && $amount >= (float) $params->get('min_receipt_amount', 250)) { + $receiptId = \Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::generate($donationId); + if ($receiptId) { + \Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::sendReceipt($receiptId); + } + } + + $this->submitted = true; + } +} diff --git a/source/packages/com_mokosuitenpo/site/src/View/VolunteerSignup/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/VolunteerSignup/HtmlView.php new file mode 100644 index 0000000..6734c51 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/View/VolunteerSignup/HtmlView.php @@ -0,0 +1,61 @@ +stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats(); + + if ($app->getInput()->getMethod() === 'POST' && \Joomla\CMS\Session\Session::checkToken()) { + $input = $app->getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $name = $input->getString('name', ''); + $email = $input->getString('email', ''); + $phone = $input->getString('phone', ''); + + if ($name && $email) { + // Find or create contact + $db->setQuery($db->getQuery(true)->select('id')->from('#__contact_details') + ->where($db->quoteName('email_to') . ' = ' . $db->quote($email))); + $contactId = (int) $db->loadResult(); + + if (!$contactId) { + $db->insertObject('#__contact_details', (object) [ + 'name' => $name, 'email_to' => $email, 'telephone' => $phone, + 'published' => 1, 'created' => Factory::getDate()->toSql(), + ], 'id'); + $contactId = $db->insertid(); + } + + $skills = $input->get('skills', [], 'ARRAY'); + $availability = []; + foreach (['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] as $day) { + if ($input->getInt($day, 0)) $availability[$day] = true; + } + + \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::register( + (int) $contactId, $skills, $availability + ); + + $this->submitted = true; + } + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/site/tmpl/campaignpage/default.php b/source/packages/com_mokosuitenpo/site/tmpl/campaignpage/default.php new file mode 100644 index 0000000..b14c8ca --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/tmpl/campaignpage/default.php @@ -0,0 +1,41 @@ +campaign; +$t = $this->thermometer; +$donors = $this->recentDonors; +if (!$c) return; +?> +
+
+

escape($c->title); ?>

+image) : ?> +description) : ?>

escape($c->description)); ?>

+
+ +thermometer) : ?> +
+
+

$raised, 0); ?>

$goal, 0); ?> goal

+
pct; ?>%
+
donors; ?> donors$remaining, 0); ?> to go
+
+
+ + + + + +
+
Recent Supporters
+
+ +
+escape($d->donor_name); ?> +$amount, 0); ?> +
+ +
+
+ +
diff --git a/source/packages/com_mokosuitenpo/site/tmpl/donate/default.php b/source/packages/com_mokosuitenpo/site/tmpl/donate/default.php new file mode 100644 index 0000000..27159c8 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/tmpl/donate/default.php @@ -0,0 +1,104 @@ +orgInfo; +$campaigns = $this->campaigns; +$funds = $this->funds; + +if ($this->submitted) : ?> +
+
+

Thank You!

+

Your donation has been received. A receipt will be emailed to you shortly.

+
+ + +
+
+

Support escape($org->name); ?>

+

Your generosity makes our work possible.

+
+ +
+
+ +
+
+
diff --git a/source/packages/com_mokosuitenpo/site/tmpl/volunteersignup/default.php b/source/packages/com_mokosuitenpo/site/tmpl/volunteersignup/default.php new file mode 100644 index 0000000..7beac09 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/tmpl/volunteersignup/default.php @@ -0,0 +1,36 @@ +stats; +if ($this->submitted) : ?> +
+ +

Welcome aboard!

+

Thank you for volunteering. We will be in touch soon.

+
+ +
+

Volunteer With Us

+

active; ?> volunteers have contributed total_hours,0); ?> hours

+
+
+
+
+
+
+
+
+ +
+ +
+
+'Mon','tuesday'=>'Tue','wednesday'=>'Wed','thursday'=>'Thu','friday'=>'Fri','saturday'=>'Sat','sunday'=>'Sun'] as $k=>$l) : ?> +
+ +
+ + +
+
+
-- 2.52.0 From c9c5ff725419a82d363ec1ac13f8d68a92706415 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 08:14:30 -0500 Subject: [PATCH 09/17] feat: events, volunteers, memberships views + grant/membership/event helpers GrantHelper: pipeline summary, deadline tracking, report due dates. MembershipHelper: active members, expiring, revenue summary, renewal. EventHelper: upcoming events, registration, check-in, capacity tracking. Admin views: Events list, Volunteers with hours stats, Memberships with annual dues summary. All with templates. --- .../admin/src/View/Events/HtmlView.php | 20 +++++ .../admin/src/View/Memberships/HtmlView.php | 23 +++++ .../admin/src/View/Volunteers/HtmlView.php | 32 +++++++ .../admin/tmpl/events/default.php | 9 ++ .../admin/tmpl/memberships/default.php | 10 +++ .../admin/tmpl/volunteers/default.php | 10 +++ .../src/Helper/EventHelper.php | 87 +++++++++++++++++++ .../src/Helper/GrantHelper.php | 72 +++++++++++++++ .../src/Helper/MembershipHelper.php | 75 ++++++++++++++++ 9 files changed, 338 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Events/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Memberships/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/View/Volunteers/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/events/default.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/memberships/default.php create mode 100644 source/packages/com_mokosuitenpo/admin/tmpl/volunteers/default.php create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/EventHelper.php create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/GrantHelper.php create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/MembershipHelper.php diff --git a/source/packages/com_mokosuitenpo/admin/src/View/Events/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Events/HtmlView.php new file mode 100644 index 0000000..a5af41c --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/View/Events/HtmlView.php @@ -0,0 +1,20 @@ +events = \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::getUpcomingEvents(30); + ToolbarHelper::title('NPO — Events', 'icon-calendar'); + ToolbarHelper::addNew('events.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/View/Memberships/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Memberships/HtmlView.php new file mode 100644 index 0000000..badcdc9 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/View/Memberships/HtmlView.php @@ -0,0 +1,23 @@ +members = \Moko\Plugin\System\MokoSuiteNpo\Helper\MembershipHelper::getActiveMembers(); + $this->summary = \Moko\Plugin\System\MokoSuiteNpo\Helper\MembershipHelper::getSummary(); + + ToolbarHelper::title('NPO — Memberships', 'icon-id-card'); + ToolbarHelper::addNew('memberships.add'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/View/Volunteers/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Volunteers/HtmlView.php new file mode 100644 index 0000000..0832bb0 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/View/Volunteers/HtmlView.php @@ -0,0 +1,32 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name AS volunteer_name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_volunteers', 'v')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->order('cd.name ASC')); + $this->volunteers = $db->loadObjectList() ?: []; + + $this->stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats(); + + ToolbarHelper::title('NPO — Volunteers', 'icon-users'); + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/events/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/events/default.php new file mode 100644 index 0000000..2ded718 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/events/default.php @@ -0,0 +1,9 @@ +events; +?> + + + + +
EventTypeDateRegisteredStatus
escape($e->title); ?>event_type); ?>start_date)); ?>registered; ?>status); ?>
diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/memberships/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/memberships/default.php new file mode 100644 index 0000000..be729ae --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/memberships/default.php @@ -0,0 +1,10 @@ +members;$sum=$this->summary; +?> +
total_active; ?>
Active
$annual_revenue,0); ?>
Annual Dues
+ + + + +
MemberLevelDuesExpires
escape($m->member_name); ?>membership_level); ?>$annual_dues,0); ?>end_date?date("M j, Y",strtotime($m->end_date)):"Lifetime"; ?>
diff --git a/source/packages/com_mokosuitenpo/admin/tmpl/volunteers/default.php b/source/packages/com_mokosuitenpo/admin/tmpl/volunteers/default.php new file mode 100644 index 0000000..bb64779 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/tmpl/volunteers/default.php @@ -0,0 +1,10 @@ +volunteers;$s=$this->stats; +?> +
active; ?>
Active
total_hours,0); ?>
Hours
+ + + + +
NameStatusHours
escape($v->volunteer_name); ?>status); ?>total_hours,1); ?>
diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/EventHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/EventHelper.php new file mode 100644 index 0000000..f584ad8 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/EventHelper.php @@ -0,0 +1,87 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('e.*, c.title AS campaign_title') + ->select('(SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations r WHERE r.event_id = e.id) AS registered') + ->from($db->quoteName('#__mokosuitenpo_events', 'e')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_campaigns', 'c') . ' ON c.id = e.campaign_id') + ->where($db->quoteName('e.start_date') . ' >= NOW()') + ->where($db->quoteName('e.status') . ' != ' . $db->quote('cancelled')) + ->order('e.start_date ASC'), 0, $limit); + + $events = $db->loadObjectList() ?: []; + + foreach ($events as &$ev) { + $ev->spots_remaining = $ev->capacity ? max(0, (int) $ev->capacity - (int) $ev->registered) : null; + $ev->is_sold_out = $ev->capacity && (int) $ev->registered >= (int) $ev->capacity; + } + + return $events; + } + + public static function register(int $eventId, array $data): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $reg = (object) [ + 'event_id' => $eventId, + 'contact_id' => (int) ($data['contact_id'] ?? 0) ?: null, + 'guest_name' => $data['name'] ?? '', + 'guest_email' => $data['email'] ?? '', + 'guest_phone' => $data['phone'] ?? '', + 'ticket_count'=> (int) ($data['tickets'] ?? 1), + 'amount_paid' => (float) ($data['amount'] ?? 0), + 'dietary_restrictions' => $data['dietary'] ?? '', + 'status' => 'registered', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_event_registrations', $reg, 'id'); + + // Update event registered count + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_events') + ->set('registered = registered + ' . (int) $reg->ticket_count) + ->set('actual_revenue = actual_revenue + ' . (float) $reg->amount_paid) + ->where('id = ' . $eventId)); + $db->execute(); + + return (int) $reg->id; + } + + public static function getEventRegistrations(int $eventId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_event_registrations') + ->where('event_id = ' . $eventId) + ->order('created ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function checkIn(int $registrationId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->updateObject('#__mokosuitenpo_event_registrations', (object) [ + 'id' => $registrationId, 'status' => 'attended', 'checked_in_at' => Factory::getDate()->toSql(), + ], 'id'); + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/GrantHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/GrantHelper.php new file mode 100644 index 0000000..12bb2ed --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/GrantHelper.php @@ -0,0 +1,72 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total') + ->select('SUM(CASE WHEN status IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ',' . $db->quote('submitted') . ',' . $db->quote('pending') . ') THEN amount_requested ELSE 0 END) AS pipeline_value') + ->select('SUM(CASE WHEN status = ' . $db->quote('awarded') . ' THEN amount_awarded ELSE 0 END) AS awarded_value') + ->select('SUM(CASE WHEN status = ' . $db->quote('awarded') . ' THEN 1 ELSE 0 END) AS awarded_count') + ->select('SUM(CASE WHEN status = ' . $db->quote('declined') . ' THEN 1 ELSE 0 END) AS declined_count') + ->from('#__mokosuitenpo_grants')); + + $stats = $db->loadObject() ?: (object) ['total' => 0, 'pipeline_value' => 0, 'awarded_value' => 0, 'awarded_count' => 0, 'declined_count' => 0]; + $closed = (int) $stats->awarded_count + (int) $stats->declined_count; + $stats->win_rate = $closed > 0 ? round($stats->awarded_count / $closed * 100) : 0; + + return $stats; + } + + public static function getUpcomingDeadlines(int $daysAhead = 30): array + { + $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 ' . $daysAhead . ' DAY)') + ->order('application_deadline ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getReportsDue(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_grants') + ->where($db->quoteName('status') . ' IN (' . $db->quote('awarded') . ',' . $db->quote('reporting') . ')') + ->where($db->quoteName('next_report_due') . ' IS NOT NULL') + ->where($db->quoteName('next_report_due') . ' <= DATE_ADD(CURDATE(), INTERVAL 30 DAY)') + ->order('next_report_due ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function advanceStatus(int $grantId, string $newStatus): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $update = (object) ['id' => $grantId, 'status' => $newStatus, 'modified' => Factory::getDate()->toSql()]; + + if ($newStatus === 'submitted') $update->submitted_date = date('Y-m-d'); + if ($newStatus === 'awarded') $update->award_date = date('Y-m-d'); + + $db->updateObject('#__mokosuitenpo_grants', $update, 'id'); + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/MembershipHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/MembershipHelper.php new file mode 100644 index 0000000..cd6ae8f --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/MembershipHelper.php @@ -0,0 +1,75 @@ +get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('m.*, cd.name AS member_name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_memberships', 'm')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = m.contact_id') + ->where($db->quoteName('m.status') . ' = ' . $db->quote('active')) + ->order('m.end_date ASC'); + + if ($level) $query->where($db->quoteName('m.membership_level') . ' = ' . $db->quote($level)); + + $db->setQuery($query); + return $db->loadObjectList() ?: []; + } + + public static function getExpiring(int $daysAhead = 30): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('m.*, cd.name AS member_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_memberships', 'm')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = m.contact_id') + ->where($db->quoteName('m.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('m.end_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $daysAhead . ' DAY)') + ->order('m.end_date ASC')); + + return $db->loadObjectList() ?: []; + } + + public static function getSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_active') + ->select('COALESCE(SUM(annual_dues), 0) AS annual_revenue') + ->select('SUM(CASE WHEN membership_level = ' . $db->quote('lifetime') . ' THEN 1 ELSE 0 END) AS lifetime_count') + ->from('#__mokosuitenpo_memberships') + ->where($db->quoteName('status') . ' = ' . $db->quote('active'))); + + return $db->loadObject() ?: (object) ['total_active' => 0, 'annual_revenue' => 0, 'lifetime_count' => 0]; + } + + public static function renew(int $membershipId, int $months = 12): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('end_date')->from('#__mokosuitenpo_memberships')->where('id = ' . $membershipId)); + $currentEnd = $db->loadResult(); + + $newEnd = date('Y-m-d', strtotime(($currentEnd && strtotime($currentEnd) > time() ? $currentEnd : 'now') . " +{$months} months")); + + $db->updateObject('#__mokosuitenpo_memberships', (object) [ + 'id' => $membershipId, 'end_date' => $newEnd, 'status' => 'active', 'modified' => Factory::getDate()->toSql(), + ], 'id'); + + return true; + } +} -- 2.52.0 From 38dabdce76980538609537dc5871926343d382d3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 08:37:25 -0500 Subject: [PATCH 10/17] chore: CI workflow, language files, CSS, JS infrastructure --- .gitea/workflows/build.yaml | 33 +++++++++++++++++++ .../com_mokosuitenpo/media/css/npo.css | 8 +++++ .../com_mokosuitenpo/media/js/donate.js | 10 ++++++ .../en-GB/plg_system_mokosuitenpo.ini | 2 ++ .../en-GB/plg_system_mokosuitenpo.sys.ini | 2 ++ 5 files changed, 55 insertions(+) create mode 100644 .gitea/workflows/build.yaml create mode 100644 source/packages/com_mokosuitenpo/media/css/npo.css create mode 100644 source/packages/com_mokosuitenpo/media/js/donate.js create mode 100644 source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.ini create mode 100644 source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.sys.ini diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..5281af2 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,33 @@ +name: Build Package +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build package ZIP + run: | + cd source + # Create individual package ZIPs + for pkg_dir in packages/*/; do + pkg_name=$(basename "$pkg_dir") + cd "$pkg_dir" + zip -r "../../${pkg_name}.zip" . -x "*.git*" + cd ../.. + done + # Create main package ZIP with all sub-packages + manifest + zip -j "pkg_mokosuitenpo.zip" pkg_*.xml script.php updates.xml *.zip 2>/dev/null || true + ls -la *.zip + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: source/pkg_mokosuitenpo.zip + generate_release_notes: true diff --git a/source/packages/com_mokosuitenpo/media/css/npo.css b/source/packages/com_mokosuitenpo/media/css/npo.css new file mode 100644 index 0000000..ae6ad10 --- /dev/null +++ b/source/packages/com_mokosuitenpo/media/css/npo.css @@ -0,0 +1,8 @@ +/* MokoSuite NPO Styles */ +.mokosuitenpo-dashboard .card { border-radius: 0.5rem; } +.donation-amount-btn.active { background-color: #198754 !important; color: #fff !important; } +.thermometer-bar { transition: width 0.8s ease-in-out; } +.donor-level-prospect { color: #6c757d; } +.donor-level-major { color: #ffc107; font-weight: bold; } +.donor-level-legacy { color: #198754; font-weight: bold; } +@media print { .btn, .toolbar { display: none !important; } } diff --git a/source/packages/com_mokosuitenpo/media/js/donate.js b/source/packages/com_mokosuitenpo/media/js/donate.js new file mode 100644 index 0000000..6ec79b7 --- /dev/null +++ b/source/packages/com_mokosuitenpo/media/js/donate.js @@ -0,0 +1,10 @@ +document.addEventListener('DOMContentLoaded', function() { + var buttons = document.querySelectorAll('.donation-amount-btn, [onclick*="amount"]'); + var amountInput = document.getElementById('amount'); + buttons.forEach(function(btn) { + btn.addEventListener('click', function() { + buttons.forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); + }); + }); +}); diff --git a/source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.ini b/source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.ini new file mode 100644 index 0000000..21a19b2 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITENPO="System - MokoSuite NPO" +PLG_SYSTEM_MOKOSUITENPO_DESC="MokoSuite NPO system plugin - nonprofit database schema and helpers." diff --git a/source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.sys.ini b/source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.sys.ini new file mode 100644 index 0000000..4acc545 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/language/en-GB/plg_system_mokosuitenpo.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_MOKOSUITENPO="System - MokoSuite NPO" +PLG_SYSTEM_MOKOSUITENPO_DESC="MokoSuite NPO system plugin." -- 2.52.0 From d257afa23e896234191f89d15fc523896f91504f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 13 Jun 2026 08:57:23 -0500 Subject: [PATCH 11/17] feat: events/volunteers/memberships/grants API controller NpoEventsController: event listing + registration + check-in, volunteer hours logging, membership summary, grant pipeline with reports due. Permission checks on admin-only endpoints (npo.grants, core.manage). --- .../src/Controller/NpoEventsController.php | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/api/src/Controller/NpoEventsController.php diff --git a/source/packages/com_mokosuitenpo/api/src/Controller/NpoEventsController.php b/source/packages/com_mokosuitenpo/api/src/Controller/NpoEventsController.php new file mode 100644 index 0000000..19fc920 --- /dev/null +++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoEventsController.php @@ -0,0 +1,103 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listEvents(): void + { + $events = \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::getUpcomingEvents(50); + $this->sendJson($events); + } + + public function registerForEvent(): void + { + $input = Factory::getApplication()->getInput(); + + $regId = \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::register( + $input->getInt('event_id', 0), [ + 'contact_id' => $input->getInt('contact_id', 0), + 'name' => $input->getString('name', ''), + 'email' => $input->getString('email', ''), + 'phone' => $input->getString('phone', ''), + 'tickets' => $input->getInt('tickets', 1), + 'amount' => $input->getFloat('amount', 0), + 'dietary' => $input->getString('dietary', ''), + ] + ); + + $this->sendJson(['id' => $regId, 'message' => 'Registered.']); + } + + public function checkIn(): void + { + $this->requireAuth('core.manage'); + $regId = Factory::getApplication()->getInput()->getInt('registration_id', 0); + \Moko\Plugin\System\MokoSuiteNpo\Helper\EventHelper::checkIn($regId); + $this->sendJson(['message' => 'Checked in.']); + } + + public function listVolunteers(): void + { + $this->requireAuth('core.manage'); + $stats = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::getVolunteerStats(); + $this->sendJson($stats); + } + + public function logVolunteerHours(): void + { + $this->requireAuth('core.manage'); + $input = Factory::getApplication()->getInput(); + + $logId = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::logHours( + $input->getInt('volunteer_id', 0), + $input->getString('activity', ''), + $input->getFloat('hours', 0), + $input->getString('date', date('Y-m-d')), + $input->getString('notes', '') + ); + + $this->sendJson(['id' => $logId, 'message' => 'Hours logged.']); + } + + public function membershipSummary(): void + { + $this->requireAuth('core.manage'); + $summary = \Moko\Plugin\System\MokoSuiteNpo\Helper\MembershipHelper::getSummary(); + $this->sendJson($summary); + } + + public function grantPipeline(): void + { + $this->requireAuth('npo.grants'); + $pipeline = \Moko\Plugin\System\MokoSuiteNpo\Helper\GrantHelper::getPipelineSummary(); + $reports = \Moko\Plugin\System\MokoSuiteNpo\Helper\GrantHelper::getReportsDue(); + $this->sendJson(['pipeline' => $pipeline, 'reports_due' => $reports]); + } + + private function sendJson(mixed $data): void + { + $app = Factory::getApplication(); + $app->getDocument()->setMimeEncoding('application/json'); + echo json_encode(['data' => $data], JSON_THROW_ON_ERROR); + $app->close(); + } +} -- 2.52.0 From 33f4da29f94a9380107c712db1945306ef678d7e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 15 Jun 2026 00:35:03 -0500 Subject: [PATCH 12/17] Add Volunteers/Grants API controllers, EventCalendar and GrantPortal site views - NpoVolunteersController: list, register, log hours, stats - NpoGrantsController: grants CRUD, campaigns with progress tracking - EventCalendar: public monthly view with registration counts - GrantPortal: public grant listing with summary stats --- .../src/Controller/NpoGrantsController.php | 132 +++++++++++++++++ .../Controller/NpoVolunteersController.php | 135 ++++++++++++++++++ .../site/src/View/EventCalendar/HtmlView.php | 48 +++++++ .../site/src/View/GrantPortal/HtmlView.php | 40 ++++++ .../site/tmpl/eventcalendar/default.php | 57 ++++++++ .../site/tmpl/grantportal/default.php | 74 ++++++++++ 6 files changed, 486 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/api/src/Controller/NpoGrantsController.php create mode 100644 source/packages/com_mokosuitenpo/api/src/Controller/NpoVolunteersController.php create mode 100644 source/packages/com_mokosuitenpo/site/src/View/EventCalendar/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/site/src/View/GrantPortal/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/site/tmpl/eventcalendar/default.php create mode 100644 source/packages/com_mokosuitenpo/site/tmpl/grantportal/default.php diff --git a/source/packages/com_mokosuitenpo/api/src/Controller/NpoGrantsController.php b/source/packages/com_mokosuitenpo/api/src/Controller/NpoGrantsController.php new file mode 100644 index 0000000..5cc1be8 --- /dev/null +++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoGrantsController.php @@ -0,0 +1,132 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listGrants(): void + { + $this->requireAuth('npo.grants'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('g.*') + ->from($db->quoteName('#__mokosuitenpo_grants', 'g')) + ->order('g.deadline DESC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('g.status') . ' = ' . $db->quote($status)); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function getGrant(): void + { + $this->requireAuth('npo.grants'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_grants') + ->where('id = ' . $id)); + $grant = $db->loadObject(); + + if (!$grant) { + http_response_code(404); + $this->sendJson(['error' => 'Grant not found']); + return; + } + + $this->sendJson($grant); + } + + public function listCampaigns(): void + { + $this->requireAuth('npo.campaigns'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('c.*') + ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised') + ->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donation_count') + ->from($db->quoteName('#__mokosuitenpo_campaigns', 'c')) + ->order('c.start_date DESC')); + + $campaigns = $db->loadObjectList() ?: []; + + foreach ($campaigns as &$c) { + $c->progress_pct = (float) $c->goal > 0 ? round((float) $c->raised / (float) $c->goal * 100, 1) : 0; + } + + $this->sendJson($campaigns); + } + + public function getCampaign(): void + { + $this->requireAuth('npo.campaigns'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('c.*') + ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised') + ->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donation_count') + ->from($db->quoteName('#__mokosuitenpo_campaigns', 'c')) + ->where('c.id = ' . $id)); + $campaign = $db->loadObject(); + + if (!$campaign) { + http_response_code(404); + $this->sendJson(['error' => 'Campaign not found']); + return; + } + + $campaign->progress_pct = (float) $campaign->goal > 0 ? round((float) $campaign->raised / (float) $campaign->goal * 100, 1) : 0; + + // Recent donations + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name') + ->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') + ->where('d.campaign_id = ' . $id) + ->order('d.donation_date DESC'), 0, 20); + $campaign->recent_donations = $db->loadObjectList() ?: []; + + $this->sendJson($campaign); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitenpo/api/src/Controller/NpoVolunteersController.php b/source/packages/com_mokosuitenpo/api/src/Controller/NpoVolunteersController.php new file mode 100644 index 0000000..d5fd523 --- /dev/null +++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoVolunteersController.php @@ -0,0 +1,135 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listVolunteers(): void + { + $this->requireAuth('npo.volunteers'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('v.*, cd.name AS volunteer_name, cd.email_to AS email, cd.telephone') + ->select('(SELECT COALESCE(SUM(vh.hours), 0) FROM #__mokosuitenpo_volunteer_hours vh WHERE vh.volunteer_id = v.id) AS total_hours') + ->from($db->quoteName('#__mokosuitenpo_volunteers', 'v')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->order('cd.name ASC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status)); + + $skill = $input->getString('skill', ''); + if ($skill) $query->where($db->quoteName('v.skills') . ' LIKE ' . $db->quote('%' . $skill . '%')); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function register(): void + { + $this->requireAuth('npo.volunteers'); + $input = Factory::getApplication()->getInput(); + + $volunteerId = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::registerVolunteer( + $input->getInt('contact_id', 0), + $input->getString('skills', ''), + $input->getString('availability', ''), + $input->getString('notes', '') + ); + + $this->sendJson(['success' => true, 'volunteer_id' => $volunteerId]); + } + + public function getVolunteer(): void + { + $this->requireAuth('npo.volunteers'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name AS volunteer_name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_volunteers', 'v')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->where('v.id = ' . $id)); + $volunteer = $db->loadObject(); + + if (!$volunteer) { + http_response_code(404); + $this->sendJson(['error' => 'Volunteer not found']); + return; + } + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_volunteer_hours') + ->where('volunteer_id = ' . $id) + ->order('date DESC'), 0, 50); + $volunteer->recent_hours = $db->loadObjectList() ?: []; + + $this->sendJson($volunteer); + } + + public function logHours(): void + { + $this->requireAuth('npo.volunteers'); + $input = Factory::getApplication()->getInput(); + + $hourId = \Moko\Plugin\System\MokoSuiteNpo\Helper\VolunteerHelper::logHours( + $input->getInt('id', 0), + $input->getFloat('hours', 0), + $input->getString('date', date('Y-m-d')), + $input->getString('activity', ''), + $input->getString('notes', '') + ); + + $this->sendJson(['success' => true, 'hour_id' => $hourId]); + } + + public function stats(): void + { + $this->requireAuth('npo.volunteers'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_volunteers') + ->select('SUM(CASE WHEN status = ' . $db->quote('active') . ' THEN 1 ELSE 0 END) AS active') + ->select('(SELECT COALESCE(SUM(hours), 0) FROM #__mokosuitenpo_volunteer_hours) AS total_hours') + ->select('(SELECT COALESCE(SUM(hours), 0) FROM #__mokosuitenpo_volunteer_hours WHERE MONTH(date) = MONTH(CURDATE()) AND YEAR(date) = YEAR(CURDATE())) AS hours_this_month') + ->from('#__mokosuitenpo_volunteers')); + + $this->sendJson($db->loadObject()); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} diff --git a/source/packages/com_mokosuitenpo/site/src/View/EventCalendar/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/EventCalendar/HtmlView.php new file mode 100644 index 0000000..dbfe9c2 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/View/EventCalendar/HtmlView.php @@ -0,0 +1,48 @@ +getInput(); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $this->month = $input->getString('month', date('m')); + $this->year = $input->getString('year', date('Y')); + + $startDate = $this->year . '-' . $this->month . '-01'; + $endDate = date('Y-m-t', strtotime($startDate)); + + $db->setQuery($db->getQuery(true) + ->select('e.*') + ->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations r WHERE r.event_id = e.id), 0) AS registration_count') + ->from($db->quoteName('#__mokosuitenpo_events', 'e')) + ->where($db->quoteName('e.published') . ' = 1') + ->where($db->quoteName('e.event_date') . ' BETWEEN ' . $db->quote($startDate) . ' AND ' . $db->quote($endDate)) + ->order('e.event_date ASC, e.start_time ASC')); + $this->events = $db->loadObjectList() ?: []; + + foreach ($this->events as &$event) { + $event->spots_left = (int) $event->capacity > 0 + ? max(0, (int) $event->capacity - (int) $event->registration_count) + : null; + } + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/site/src/View/GrantPortal/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/GrantPortal/HtmlView.php new file mode 100644 index 0000000..a87d136 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/View/GrantPortal/HtmlView.php @@ -0,0 +1,40 @@ +get(DatabaseInterface::class); + + // Active/awarded grants + $db->setQuery($db->getQuery(true) + ->select('g.*') + ->from($db->quoteName('#__mokosuitenpo_grants', 'g')) + ->where($db->quoteName('g.status') . ' IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ',' . $db->quote('reporting') . ')') + ->order('g.deadline ASC')); + $this->activeGrants = $db->loadObjectList() ?: []; + + // Summary stats + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_grants') + ->select('COALESCE(SUM(CASE WHEN status IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ') THEN amount END), 0) AS total_awarded') + ->select('COALESCE(SUM(CASE WHEN status = ' . $db->quote('pending') . ' THEN amount END), 0) AS pending_amount') + ->from('#__mokosuitenpo_grants')); + $this->summary = $db->loadObject() ?: (object) ['total_grants' => 0, 'total_awarded' => 0, 'pending_amount' => 0]; + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/site/tmpl/eventcalendar/default.php b/source/packages/com_mokosuitenpo/site/tmpl/eventcalendar/default.php new file mode 100644 index 0000000..3751f0d --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/tmpl/eventcalendar/default.php @@ -0,0 +1,57 @@ +year . '-' . $this->month . '-01 -1 month')); +$nextMonth = date('Y-m', strtotime($this->year . '-' . $this->month . '-01 +1 month')); +[$prevY, $prevM] = explode('-', $prevMonth); +[$nextY, $nextM] = explode('-', $nextMonth); + +$monthName = date('F Y', strtotime($this->year . '-' . $this->month . '-01')); +?> +
+ + + events)) : ?> +
No events scheduled for .
+ +
+ events as $event) : ?> +
+
+
+
+ event_date)); ?> + start_time) : ?> + · start_time)); ?> + +
+
title); ?>
+ location) : ?> +

location); ?>

+ +

description ?? '', 0, 150)); ?>

+
+ +
+
+ +
+ +
diff --git a/source/packages/com_mokosuitenpo/site/tmpl/grantportal/default.php b/source/packages/com_mokosuitenpo/site/tmpl/grantportal/default.php new file mode 100644 index 0000000..b3bcc1f --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/tmpl/grantportal/default.php @@ -0,0 +1,74 @@ +summary; +?> +
+

Grant Programs

+ +
+
+
+
+

$total_awarded); ?>

+

Total Awarded

+
+
+
+
+
+
+

total_grants; ?>

+

Grants

+
+
+
+
+
+
+

$pending_amount); ?>

+

Pending

+
+
+
+
+ + activeGrants)) : ?> +
No active grant programs at this time.
+ +
+ + + + + + + + + + + + activeGrants as $grant) : ?> + + + + + + + + + +
GrantFunderAmountStatusDeadline
+ title); ?> + description)) : ?> +
description, 0, 100)); ?> + +
funder ?? ''); ?>$amount); ?> + status); ?> + deadline ? date('M j, Y', strtotime($grant->deadline)) : '—'; ?>
+
+ +
-- 2.52.0 From 29e87cb572f2656d9cd7bb4dbee947068de6714f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 15 Jun 2026 00:49:53 -0500 Subject: [PATCH 13/17] =?UTF-8?q?Add=207=20admin=20models=20=E2=80=94=20Do?= =?UTF-8?q?nors,=20Donations,=20Campaigns,=20Grants,=20Volunteers,=20Event?= =?UTF-8?q?s,=20Memberships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All use Joomla 6 BaseDatabaseModel with $this->getDatabase() --- .../admin/src/Model/CampaignsModel.php | 31 +++++++++++ .../admin/src/Model/DonationsModel.php | 48 +++++++++++++++++ .../admin/src/Model/DonorsModel.php | 52 +++++++++++++++++++ .../admin/src/Model/EventsModel.php | 26 ++++++++++ .../admin/src/Model/GrantsModel.php | 41 +++++++++++++++ .../admin/src/Model/MembershipsModel.php | 40 ++++++++++++++ .../admin/src/Model/VolunteersModel.php | 26 ++++++++++ 7 files changed, 264 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/CampaignsModel.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/DonationsModel.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/EventsModel.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/GrantsModel.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/MembershipsModel.php create mode 100644 source/packages/com_mokosuitenpo/admin/src/Model/VolunteersModel.php diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/CampaignsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/CampaignsModel.php new file mode 100644 index 0000000..8c9e251 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/CampaignsModel.php @@ -0,0 +1,31 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('c.*') + ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised') + ->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donation_count') + ->from($db->quoteName('#__mokosuitenpo_campaigns', 'c')) + ->order('c.start_date DESC'); + + if ($status) $query->where($db->quoteName('c.status') . ' = ' . $db->quote($status)); + + $db->setQuery($query, 0, $limit); + $campaigns = $db->loadObjectList() ?: []; + + foreach ($campaigns as &$c) { + $c->progress_pct = (float) $c->goal > 0 ? round((float) $c->raised / (float) $c->goal * 100, 1) : 0; + } + + return $campaigns; + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/DonationsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/DonationsModel.php new file mode 100644 index 0000000..0a2035b --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/DonationsModel.php @@ -0,0 +1,48 @@ +getDatabase(); + $query = $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') + ->order('d.donation_date DESC'); + + if ($from) $query->where($db->quoteName('d.donation_date') . ' >= ' . $db->quote($from)); + if ($to) $query->where($db->quoteName('d.donation_date') . ' <= ' . $db->quote($to)); + if ($donorId) $query->where('d.donor_id = ' . $donorId); + if ($fundId) $query->where('d.fund_id = ' . $fundId); + if ($campaignId) $query->where('d.campaign_id = ' . $campaignId); + + $db->setQuery($query, $offset, $limit); + return $db->loadObjectList() ?: []; + } + + public function getSummary(string $from = '', string $to = ''): object + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('COUNT(*) AS total_donations') + ->select('COALESCE(SUM(amount), 0) AS total_amount') + ->select('COALESCE(AVG(amount), 0) AS avg_amount') + ->select('COUNT(DISTINCT donor_id) AS unique_donors') + ->from('#__mokosuitenpo_donations'); + + if ($from) $query->where($db->quoteName('donation_date') . ' >= ' . $db->quote($from)); + if ($to) $query->where($db->quoteName('donation_date') . ' <= ' . $db->quote($to)); + + $db->setQuery($query); + return $db->loadObject() ?: (object) ['total_donations' => 0, 'total_amount' => 0, 'avg_amount' => 0, 'unique_donors' => 0]; + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php new file mode 100644 index 0000000..ae3072e --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php @@ -0,0 +1,52 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to AS email, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->order('d.last_gift_date DESC'); + + if ($search) { + $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') + . ' OR ' . $db->quoteName('cd.email_to') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + } + if ($type) $query->where($db->quoteName('d.donor_type') . ' = ' . $db->quote($type)); + if ($level) $query->where($db->quoteName('d.donor_level') . ' = ' . $db->quote($level)); + + $db->setQuery($query, $offset, $limit); + return $db->loadObjectList() ?: []; + } + + public function getDonor(int $id): ?object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to, cd.telephone, cd.address') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where('d.id = ' . $id)); + return $db->loadObject(); + } + + public function getTopDonors(int $limit = 20): array + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('d.lifetime_giving') . ' > 0') + ->order('d.lifetime_giving DESC'), 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/EventsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/EventsModel.php new file mode 100644 index 0000000..56fa15a --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/EventsModel.php @@ -0,0 +1,26 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('e.*') + ->select('COALESCE((SELECT COUNT(*) FROM #__mokosuitenpo_event_registrations r WHERE r.event_id = e.id), 0) AS registrations') + ->from($db->quoteName('#__mokosuitenpo_events', 'e')) + ->order('e.event_date ASC'); + + if ($status) $query->where($db->quoteName('e.status') . ' = ' . $db->quote($status)); + if ($from) $query->where($db->quoteName('e.event_date') . ' >= ' . $db->quote($from)); + if ($to) $query->where($db->quoteName('e.event_date') . ' <= ' . $db->quote($to)); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/GrantsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/GrantsModel.php new file mode 100644 index 0000000..7006bb5 --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/GrantsModel.php @@ -0,0 +1,41 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('g.*') + ->from($db->quoteName('#__mokosuitenpo_grants', 'g')) + ->order('g.deadline DESC'); + + if ($status) $query->where($db->quoteName('g.status') . ' = ' . $db->quote($status)); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + public function getGrant(int $id): ?object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_grants')->where('id = ' . $id)); + return $db->loadObject(); + } + + public function getSummary(): object + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total') + ->select('COALESCE(SUM(CASE WHEN status IN (' . $db->quote('awarded') . ',' . $db->quote('active') . ') THEN amount END), 0) AS awarded') + ->select('COALESCE(SUM(CASE WHEN status = ' . $db->quote('pending') . ' THEN amount END), 0) AS pending') + ->from('#__mokosuitenpo_grants')); + return $db->loadObject() ?: (object) ['total' => 0, 'awarded' => 0, 'pending' => 0]; + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/MembershipsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/MembershipsModel.php new file mode 100644 index 0000000..3fe993b --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/MembershipsModel.php @@ -0,0 +1,40 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('m.*, cd.name AS member_name, cd.email_to AS email') + ->from($db->quoteName('#__mokosuitenpo_memberships', 'm')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_donors', 'd') . ' ON d.id = m.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->order('m.expiry_date ASC'); + + if ($status) $query->where($db->quoteName('m.status') . ' = ' . $db->quote($status)); + if ($type) $query->where($db->quoteName('m.membership_type') . ' = ' . $db->quote($type)); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + public function getExpiringSoon(int $days = 30): array + { + $db = $this->getDatabase(); + $db->setQuery($db->getQuery(true) + ->select('m.*, cd.name AS member_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_memberships', 'm')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_donors', 'd') . ' ON d.id = m.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('m.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('m.expiry_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ' . $days . ' DAY)') + ->order('m.expiry_date ASC')); + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/VolunteersModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/VolunteersModel.php new file mode 100644 index 0000000..dd710ed --- /dev/null +++ b/source/packages/com_mokosuitenpo/admin/src/Model/VolunteersModel.php @@ -0,0 +1,26 @@ +getDatabase(); + $query = $db->getQuery(true) + ->select('v.*, cd.name AS volunteer_name, cd.email_to AS email, cd.telephone') + ->select('(SELECT COALESCE(SUM(vh.hours), 0) FROM #__mokosuitenpo_volunteer_hours vh WHERE vh.volunteer_id = v.id) AS total_hours') + ->from($db->quoteName('#__mokosuitenpo_volunteers', 'v')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->order('cd.name ASC'); + + if ($status) $query->where($db->quoteName('v.status') . ' = ' . $db->quote($status)); + if ($search) $query->where($db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%')); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } +} -- 2.52.0 From 88cb3a2fe502ed5ddd19fcad2a3324ea7a30325a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 15 Jun 2026 05:21:51 -0500 Subject: [PATCH 14/17] =?UTF-8?q?Update=20MokoSuite=20=E2=86=92=20MokoSuit?= =?UTF-8?q?eClient=20submodule=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated .gitmodules URL and path for the MokoSuite → MokoSuiteClient rename. --- .gitmodules | 6 +++--- packages/{MokoSuite => MokoSuiteClient} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/{MokoSuite => MokoSuiteClient} (100%) diff --git a/.gitmodules b/.gitmodules index 7385cca..307b57e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "packages/MokoSuiteCRM"] path = packages/MokoSuiteCRM url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git -[submodule "packages/MokoSuite"] - path = packages/MokoSuite - url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuite.git +[submodule "packages/MokoSuiteClient"] + path = packages/MokoSuiteClient + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git diff --git a/packages/MokoSuite b/packages/MokoSuiteClient similarity index 100% rename from packages/MokoSuite rename to packages/MokoSuiteClient -- 2.52.0 From 0275564283cd570ad5b34de41aad732d2e020dfd Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Wed, 17 Jun 2026 11:41:49 -0500 Subject: [PATCH 15/17] =?UTF-8?q?Add=20MemberPortal=20site=20view=20?= =?UTF-8?q?=E2=80=94=20giving=20history,=20membership=20status,=20tax=20re?= =?UTF-8?q?ceipts,=20event=20registrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/src/View/MemberPortal/HtmlView.php | 96 +++++++++++++++++++ .../site/tmpl/memberportal/default.php | 81 ++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/site/src/View/MemberPortal/HtmlView.php create mode 100644 source/packages/com_mokosuitenpo/site/tmpl/memberportal/default.php diff --git a/source/packages/com_mokosuitenpo/site/src/View/MemberPortal/HtmlView.php b/source/packages/com_mokosuitenpo/site/src/View/MemberPortal/HtmlView.php new file mode 100644 index 0000000..4aa3a92 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/View/MemberPortal/HtmlView.php @@ -0,0 +1,96 @@ +getIdentity(); + + if (!$user || $user->guest) { + $app->enqueueMessage('Please log in to access the member portal.', 'warning'); + parent::display($tpl); + return; + } + + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Find contact and donor + $db->setQuery($db->getQuery(true) + ->select('cd.id AS contact_id, cd.name') + ->from($db->quoteName('#__contact_details', 'cd')) + ->where('cd.user_id = ' . (int) $user->id)); + $contact = $db->loadObject(); + + if (!$contact) { + parent::display($tpl); + return; + } + + // Get donor profile + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_donors') + ->where('contact_id = ' . (int) $contact->contact_id)); + $this->donor = $db->loadObject(); + + if ($this->donor) { + $this->lifetimeGiving = (float) $this->donor->lifetime_giving; + + // Membership + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_memberships') + ->where('donor_id = ' . (int) $this->donor->id) + ->where($db->quoteName('status') . ' = ' . $db->quote('active')) + ->order('expiry_date DESC')); + $this->membership = $db->loadObject(); + + // Giving history (last 2 years) + $db->setQuery($db->getQuery(true) + ->select('d.*, f.name AS fund_name, c.title AS campaign_title') + ->from($db->quoteName('#__mokosuitenpo_donations', 'd')) + ->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.donor_id = ' . (int) $this->donor->id) + ->order('d.donation_date DESC'), 0, 50); + $this->givingHistory = $db->loadObjectList() ?: []; + + // Tax receipts + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_tax_receipts') + ->where('donor_id = ' . (int) $this->donor->id) + ->order('issued_date DESC'), 0, 10); + $this->receipts = $db->loadObjectList() ?: []; + } + + // Event registrations + $db->setQuery($db->getQuery(true) + ->select('er.*, e.title AS event_title, e.event_date, e.start_time, e.location') + ->from($db->quoteName('#__mokosuitenpo_event_registrations', 'er')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_events', 'e') . ' ON e.id = er.event_id') + ->where('er.contact_id = ' . (int) $contact->contact_id) + ->order('e.event_date DESC'), 0, 20); + $this->events = $db->loadObjectList() ?: []; + + parent::display($tpl); + } +} diff --git a/source/packages/com_mokosuitenpo/site/tmpl/memberportal/default.php b/source/packages/com_mokosuitenpo/site/tmpl/memberportal/default.php new file mode 100644 index 0000000..632a960 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/tmpl/memberportal/default.php @@ -0,0 +1,81 @@ + +
+

Member Portal

+ + donor) : ?> +
No donor profile found. Make a donation to create your profile.
+ + +
+
+

$lifetimeGiving, 2); ?>

+

Lifetime Giving

+
+
+

donor->donor_level); ?>

+

Donor Level

+
+
+

donor->gift_count; ?>

+

Total Gifts

+
+
+ + membership) : ?> +
+
+
Active Membership — membership->membership_type ?? 'Standard'); ?>
+

Valid through membership->expiry_date)); ?>

+
+
+ + +

Giving History

+ givingHistory)) : ?> +

No donations on record.

+ + + + + givingHistory as $d) : ?> + + + + + + + + +
DateAmountFundCampaign
donation_date)); ?>$amount, 2); ?>fund_name ?? ''); ?>campaign_title ?? '—'); ?>
+ + + receipts)) : ?> +

Tax Receipts

+
    + receipts as $r) : ?> +
  • + Receipt #receipt_number ?? $r->id); ?> — issued_date)); ?> + $amount, 2); ?> +
  • + +
+ + + + + events)) : ?> +

Event Registrations

+ + + + events as $e) : ?> + + + + + + + +
EventDateLocation
event_title); ?>event_date)); ?>location ?? ''); ?>
+ +
-- 2.52.0 From bab40a0af60978bc273419a5db7ce7c5f073b738 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 18 Jun 2026 08:29:46 -0500 Subject: [PATCH 16/17] =?UTF-8?q?Add=20NpoCampaignsController=20API=20?= =?UTF-8?q?=E2=80=94=20campaigns=20with=20progress,=20memberships,=20expir?= =?UTF-8?q?ing=20alerts,=20dashboard=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Controller/NpoCampaignsController.php | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 source/packages/com_mokosuitenpo/api/src/Controller/NpoCampaignsController.php diff --git a/source/packages/com_mokosuitenpo/api/src/Controller/NpoCampaignsController.php b/source/packages/com_mokosuitenpo/api/src/Controller/NpoCampaignsController.php new file mode 100644 index 0000000..02b543c --- /dev/null +++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoCampaignsController.php @@ -0,0 +1,160 @@ +getIdentity(); + if (!$user || $user->guest || (!$user->authorise('core.admin') && !$user->authorise($action, 'com_mokosuitenpo'))) { + http_response_code(403); + echo json_encode(['error' => 'Access denied.']); + Factory::getApplication()->close(); + } + } + + public function listCampaigns(): void + { + $this->requireAuth('npo.campaigns'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('c.*') + ->select('COALESCE((SELECT SUM(d.amount) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS raised') + ->select('COALESCE((SELECT COUNT(DISTINCT d.donor_id) FROM #__mokosuitenpo_donations d WHERE d.campaign_id = c.id), 0) AS donor_count') + ->from($db->quoteName('#__mokosuitenpo_campaigns', 'c')) + ->order('c.start_date DESC')); + + $campaigns = $db->loadObjectList() ?: []; + + foreach ($campaigns as &$c) { + $c->progress_pct = (float) $c->goal > 0 ? round((float) $c->raised / (float) $c->goal * 100, 1) : 0; + } + + $this->sendJson($campaigns); + } + + public function getCampaign(): void + { + $this->requireAuth('npo.campaigns'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $id = Factory::getApplication()->getInput()->getInt('id', 0); + + $db->setQuery($db->getQuery(true) + ->select('*') + ->from('#__mokosuitenpo_campaigns') + ->where('id = ' . $id)); + $campaign = $db->loadObject(); + + if (!$campaign) { + http_response_code(404); + $this->sendJson(['error' => 'Campaign not found']); + return; + } + + // Recent donations for this campaign + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name') + ->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') + ->where('d.campaign_id = ' . $id) + ->order('d.donation_date DESC'), 0, 25); + $campaign->recent_donations = $db->loadObjectList() ?: []; + + $this->sendJson($campaign); + } + + public function listMemberships(): void + { + $this->requireAuth('npo.memberships'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + $input = Factory::getApplication()->getInput(); + + $query = $db->getQuery(true) + ->select('m.*, cd.name AS member_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_memberships', 'm')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_donors', 'd') . ' ON d.id = m.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->order('m.expiry_date ASC'); + + $status = $input->getString('status', ''); + if ($status) $query->where($db->quoteName('m.status') . ' = ' . $db->quote($status)); + + $db->setQuery($query, 0, 100); + $this->sendJson($db->loadObjectList() ?: []); + } + + public function expiringMemberships(): void + { + $this->requireAuth('npo.memberships'); + $days = Factory::getApplication()->getInput()->getInt('days', 30); + + $model = new \Moko\Component\MokoSuiteNpo\Administrator\Model\MembershipsModel(); + $this->sendJson($model->getExpiringSoon($days)); + } + + public function dashboard(): void + { + $this->requireAuth('core.manage'); + $db = Factory::getContainer()->get(DatabaseInterface::class); + + // Total donors + $db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokosuitenpo_donors')); + $totalDonors = (int) $db->loadResult(); + + // This month donations + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total') + ->from('#__mokosuitenpo_donations') + ->where('MONTH(donation_date) = MONTH(CURDATE())') + ->where('YEAR(donation_date) = YEAR(CURDATE())')); + $monthDonations = $db->loadObject() ?: (object) ['count' => 0, 'total' => 0]; + + // Active campaigns + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitenpo_campaigns') + ->where($db->quoteName('status') . ' = ' . $db->quote('active'))); + $activeCampaigns = (int) $db->loadResult(); + + // Active volunteers + $db->setQuery($db->getQuery(true) + ->select('COUNT(*)') + ->from('#__mokosuitenpo_volunteers') + ->where($db->quoteName('status') . ' = ' . $db->quote('active'))); + $activeVolunteers = (int) $db->loadResult(); + + $this->sendJson([ + 'total_donors' => $totalDonors, + 'month_donations' => (int) $monthDonations->count, + 'month_revenue' => (float) $monthDonations->total, + 'active_campaigns' => $activeCampaigns, + 'active_volunteers' => $activeVolunteers, + ]); + } + + private function sendJson(mixed $data): void + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + Factory::getApplication()->close(); + } +} -- 2.52.0 From 62a18c894bb1cbd6beb0f19a351dcd12f11ea4bc Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 18 Jun 2026 09:52:07 -0500 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20PR=20review=20=E2=80=94=20escape?= =?UTF-8?q?=20LIKE=20wildcards=20in=20DonorsModel=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com_mokosuitenpo/admin/src/Model/DonorsModel.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php b/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php index ae3072e..e21534c 100644 --- a/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php +++ b/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php @@ -17,8 +17,9 @@ class DonorsModel extends BaseDatabaseModel ->order('d.last_gift_date DESC'); if ($search) { - $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $search . '%') - . ' OR ' . $db->quoteName('cd.email_to') . ' LIKE ' . $db->quote('%' . $search . '%') . ')'); + $escaped = $db->escape($search, true); + $query->where('(' . $db->quoteName('cd.name') . ' LIKE ' . $db->quote('%' . $escaped . '%', false) + . ' OR ' . $db->quoteName('cd.email_to') . ' LIKE ' . $db->quote('%' . $escaped . '%', false) . ')'); } if ($type) $query->where($db->quoteName('d.donor_type') . ' = ' . $db->quote($type)); if ($level) $query->where($db->quoteName('d.donor_level') . ' = ' . $db->quote($level)); -- 2.52.0