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
+
+
$avg_gift, 0); ?>
Avg Gift
+
active; ?>
Active Volunteerstotal_hours, 0); ?> hours
+
+
+
+
+
+
+
+
+
+
+
+ escape($c->title); ?>
+ progress_pct; ?>%
+
+
+
+ $raised_amount, 0); ?> / $goal_amount, 0); ?>
+
+
+
donor_count; ?> donorsdays_remaining !== null ? " · {$c->days_remaining} days left" : ''; ?>
+
+
+
No active campaigns.
+
+
+
+
+
+
+
+
+
+
+ | Donor | Amount | Type | Date |
+
+
+
+ | anonymous_giving ? 'Anonymous' : $this->escape($d->donor_name ?? ''); ?> |
+ $amount, 2); ?> |
+ donation_type); ?> |
+ donation_date)); ?> |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Grant | Funder | Amount | Deadline | Status |
+
+ application_deadline) - time()) / 86400));
+ ?>
+
+ | escape($g->title); ?> |
+ escape($g->funder_name); ?> |
+ $amount_requested, 0); ?> |
+ days |
+ status); ?> |
+
+
+
+
+
+
+
+
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
+
+
+ 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
+