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/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..307b57e
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "packages/MokoSuiteCRM"]
+ path = packages/MokoSuiteCRM
+ url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteCRM.git
+[submodule "packages/MokoSuiteClient"]
+ path = packages/MokoSuiteClient
+ url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git
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/packages/MokoSuiteCRM b/packages/MokoSuiteCRM
new file mode 160000
index 0000000..0c9d985
--- /dev/null
+++ b/packages/MokoSuiteCRM
@@ -0,0 +1 @@
+Subproject commit 0c9d985d567beb815d00bd37bde072ad26e0380c
diff --git a/packages/MokoSuiteClient b/packages/MokoSuiteClient
new file mode 160000
index 0000000..6cd16d9
--- /dev/null
+++ b/packages/MokoSuiteClient
@@ -0,0 +1 @@
+Subproject commit 6cd16d984589fabbfd0b074b0d3308b5e64967f0
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..d58596f
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/admin/config.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
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 @@
+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..e21534c
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/admin/src/Model/DonorsModel.php
@@ -0,0 +1,53 @@
+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) {
+ $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));
+
+ $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() ?: [];
+ }
+}
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/Dashboard/HtmlView.php b/source/packages/com_mokosuitenpo/admin/src/View/Dashboard/HtmlView.php
new file mode 100644
index 0000000..5f2fea3
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/admin/src/View/Dashboard/HtmlView.php
@@ -0,0 +1,50 @@
+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/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/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/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/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/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
+
+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/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/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
+
+
+| Donor | Email | Type | Level | Lifetime | Gifts | Last 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/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;
+?>
+| Event | Type | Date | Registered | Status |
+
+| escape($e->title); ?> | event_type); ?> | start_date)); ?> | registered; ?> | status); ?> |
+
+
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"]; ?>
+| Grant | Funder | Requested | Awarded | Status | Deadline |
+application_deadline?max(0,round((strtotime($g->application_deadline)-time())/86400)):null; ?>
+">| 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/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;
+?>
+$annual_revenue,0); ?>
Annual Dues
+| Member | Level | Dues | Expires |
+
+| 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;
+?>
+
+| Name | Status | Hours |
+
+| escape($v->volunteer_name); ?> | status); ?> | total_hours,1); ?> |
+
+
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();
+ }
+}
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..dd17b2c
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/api/src/Controller/NpoDonationsController.php
@@ -0,0 +1,109 @@
+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();
+
+ $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
+ {
+ $this->requireAuth('npo.donations');
+ $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();
+ }
+}
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();
+ }
+}
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/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/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 @@
+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/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/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/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
+
+
donors; ?> donors$remaining, 0); ?> to go
+
+
+
+
+
+
+
+
+
+
+
+
+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/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.
+
+
+
+
+
+ | Grant |
+ Funder |
+ Amount |
+ Status |
+ Deadline |
+
+
+
+ activeGrants as $grant) : ?>
+
+
+ title); ?>
+ description)) : ?>
+ description, 0, 100)); ?>
+
+ |
+ funder ?? ''); ?> |
+ $amount); ?> |
+
+ status); ?>
+ |
+ deadline ? date('M j, Y', strtotime($grant->deadline)) : '—'; ?> |
+
+
+
+
+
+
+
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.
+
+
+ | Date | Amount | Fund | Campaign |
+
+ givingHistory as $d) : ?>
+
+ | 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
+
+ | Event | Date | Location |
+
+ events as $e) : ?>
+
+ | event_title); ?> |
+ event_date)); ?> |
+ location ?? ''); ?> |
+
+
+
+
+
+
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
+
+
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."
diff --git a/source/packages/plg_system_mokosuitenpo/sql/install.mysql.sql b/source/packages/plg_system_mokosuitenpo/sql/install.mysql.sql
new file mode 100644
index 0000000..a224eb4
--- /dev/null
+++ b/source/packages/plg_system_mokosuitenpo/sql/install.mysql.sql
@@ -0,0 +1,328 @@
+--
+-- MokoSuite NPO Tables
+--
+
+-- Donors piggyback on CRM contacts. This table adds NPO-specific fields.
+-- The contact_id FK links to #__contact_details.
+
+-- ============================================================
+-- Donor Profiles — extends CRM contacts with giving data
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_donors` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `contact_id` INT NOT NULL COMMENT 'FK to #__contact_details',
+ `donor_type` ENUM('individual','corporate','foundation','government','anonymous') NOT NULL DEFAULT 'individual',
+ `donor_level` ENUM('prospect','first_time','repeat','major','legacy','lapsed') NOT NULL DEFAULT 'prospect',
+ `lifetime_giving` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `largest_gift` DECIMAL(15,2) DEFAULT NULL,
+ `first_gift_date` DATE DEFAULT NULL,
+ `last_gift_date` DATE DEFAULT NULL,
+ `gift_count` INT UNSIGNED NOT NULL DEFAULT 0,
+ `preferred_fund` INT UNSIGNED DEFAULT NULL,
+ `communication_preference` ENUM('email','mail','phone','none') NOT NULL DEFAULT 'email',
+ `tax_id` VARCHAR(50) DEFAULT NULL COMMENT 'EIN for corporate donors',
+ `recognition_name` VARCHAR(255) DEFAULT NULL COMMENT 'Name for public recognition (may differ from contact)',
+ `anonymous_giving` TINYINT NOT NULL DEFAULT 0,
+ `notes` TEXT,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_contact` (`contact_id`),
+ KEY `idx_level` (`donor_level`),
+ KEY `idx_type` (`donor_type`),
+ KEY `idx_lifetime` (`lifetime_giving`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Funds — restricted vs unrestricted fund tracking
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_funds` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(255) NOT NULL,
+ `code` VARCHAR(20) NOT NULL DEFAULT '',
+ `fund_type` ENUM('unrestricted','temporarily_restricted','permanently_restricted','endowment','operating','capital') NOT NULL DEFAULT 'unrestricted',
+ `description` TEXT,
+ `gl_account_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to ERP chart of accounts',
+ `target_amount` DECIMAL(15,2) DEFAULT NULL,
+ `current_balance` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `published` TINYINT NOT NULL DEFAULT 1,
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_code` (`code`),
+ KEY `idx_type` (`fund_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Seed default funds
+INSERT IGNORE INTO `#__mokosuitenpo_funds`
+ (`id`, `name`, `code`, `fund_type`, `description`, `published`, `created`) VALUES
+ (1, 'General Operating Fund', 'GEN', 'unrestricted', 'Unrestricted general operating support.', 1, NOW()),
+ (2, 'Building Fund', 'BLDG', 'temporarily_restricted', 'Restricted for facility improvements.', 1, NOW()),
+ (3, 'Scholarship Fund', 'SCHOL', 'temporarily_restricted', 'Restricted for student scholarships.', 1, NOW()),
+ (4, 'Endowment', 'ENDOW', 'permanently_restricted', 'Permanently restricted endowment principal.', 1, NOW());
+
+-- ============================================================
+-- Donations — individual gifts with fund allocation
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_donations` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `donor_id` INT UNSIGNED NOT NULL,
+ `fund_id` INT UNSIGNED NOT NULL DEFAULT 1,
+ `campaign_id` INT UNSIGNED DEFAULT NULL,
+ `amount` DECIMAL(15,2) NOT NULL,
+ `donation_type` ENUM('cash','check','credit_card','ach','stock','crypto','in_kind','pledge','matching') NOT NULL DEFAULT 'cash',
+ `donation_date` DATE NOT NULL,
+ `payment_method` VARCHAR(50) DEFAULT NULL,
+ `payment_reference` VARCHAR(100) DEFAULT NULL COMMENT 'Check number, transaction ID',
+ `is_tax_deductible` TINYINT NOT NULL DEFAULT 1,
+ `fair_market_value` DECIMAL(15,2) DEFAULT NULL COMMENT 'For in-kind donations',
+ `in_kind_description` TEXT COMMENT 'Description of in-kind gift',
+ `is_recurring` TINYINT NOT NULL DEFAULT 0,
+ `recurring_frequency` ENUM('weekly','monthly','quarterly','annually') DEFAULT NULL,
+ `recurring_end_date` DATE DEFAULT NULL,
+ `tribute_type` ENUM('in_honor','in_memory') DEFAULT NULL,
+ `tribute_name` VARCHAR(255) DEFAULT NULL,
+ `receipt_sent` TINYINT NOT NULL DEFAULT 0,
+ `receipt_sent_date` DATE DEFAULT NULL,
+ `acknowledgment_sent` TINYINT NOT NULL DEFAULT 0,
+ `notes` TEXT,
+ `order_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to CRM orders for online donations',
+ `journal_entry_id` INT UNSIGNED DEFAULT NULL COMMENT 'FK to ERP journal entries',
+ `created` DATETIME NOT NULL,
+ `created_by` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ KEY `idx_donor` (`donor_id`),
+ KEY `idx_fund` (`fund_id`),
+ KEY `idx_campaign` (`campaign_id`),
+ KEY `idx_date` (`donation_date`),
+ KEY `idx_type` (`donation_type`),
+ KEY `idx_recurring` (`is_recurring`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Pledges — promised future donations
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_pledges` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `donor_id` INT UNSIGNED NOT NULL,
+ `fund_id` INT UNSIGNED NOT NULL DEFAULT 1,
+ `campaign_id` INT UNSIGNED DEFAULT NULL,
+ `total_amount` DECIMAL(15,2) NOT NULL,
+ `amount_fulfilled` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `pledge_date` DATE NOT NULL,
+ `due_date` DATE DEFAULT NULL,
+ `frequency` ENUM('one_time','monthly','quarterly','annually') NOT NULL DEFAULT 'one_time',
+ `installments` INT UNSIGNED NOT NULL DEFAULT 1,
+ `status` ENUM('active','fulfilled','partial','cancelled','lapsed') NOT NULL DEFAULT 'active',
+ `notes` TEXT,
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_donor` (`donor_id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_due` (`due_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Campaigns — fundraising drives
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_campaigns` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` VARCHAR(255) NOT NULL,
+ `description` TEXT,
+ `campaign_type` ENUM('annual','capital','endowment','emergency','event','peer_to_peer','crowdfunding','grant_match') NOT NULL DEFAULT 'annual',
+ `fund_id` INT UNSIGNED DEFAULT NULL,
+ `goal_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `raised_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `donor_count` INT UNSIGNED NOT NULL DEFAULT 0,
+ `start_date` DATE NOT NULL,
+ `end_date` DATE DEFAULT NULL,
+ `status` ENUM('planning','active','paused','completed','cancelled') NOT NULL DEFAULT 'planning',
+ `public_page` TINYINT NOT NULL DEFAULT 1 COMMENT 'Show on public donation page',
+ `thermometer` TINYINT NOT NULL DEFAULT 1 COMMENT 'Show progress thermometer',
+ `image` VARCHAR(500) DEFAULT NULL,
+ `created_by` INT NOT NULL DEFAULT 0,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_type` (`campaign_type`),
+ KEY `idx_dates` (`start_date`, `end_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Grants — application, award, and reporting lifecycle
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_grants` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` VARCHAR(255) NOT NULL,
+ `funder_name` VARCHAR(255) NOT NULL,
+ `funder_contact_id` INT DEFAULT NULL COMMENT 'FK to CRM contacts',
+ `amount_requested` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `amount_awarded` DECIMAL(15,2) DEFAULT NULL,
+ `fund_id` INT UNSIGNED DEFAULT NULL,
+ `status` ENUM('prospect','writing','submitted','pending','awarded','declined','reporting','closed') NOT NULL DEFAULT 'prospect',
+ `application_deadline` DATE DEFAULT NULL,
+ `submitted_date` DATE DEFAULT NULL,
+ `award_date` DATE DEFAULT NULL,
+ `start_date` DATE DEFAULT NULL,
+ `end_date` DATE DEFAULT NULL,
+ `reporting_frequency` ENUM('monthly','quarterly','semi_annual','annual','final') DEFAULT NULL,
+ `next_report_due` DATE DEFAULT NULL,
+ `restrictions` TEXT COMMENT 'Grant restrictions/requirements',
+ `match_required` TINYINT NOT NULL DEFAULT 0,
+ `match_ratio` VARCHAR(20) DEFAULT NULL COMMENT 'e.g., 1:1, 2:1',
+ `notes` TEXT,
+ `created_by` INT NOT NULL DEFAULT 0,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_funder` (`funder_contact_id`),
+ KEY `idx_deadline` (`application_deadline`),
+ KEY `idx_report_due` (`next_report_due`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Volunteers — hours, skills, availability
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_volunteers` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `contact_id` INT NOT NULL COMMENT 'FK to #__contact_details',
+ `status` ENUM('prospect','active','inactive','alumni') NOT NULL DEFAULT 'prospect',
+ `skills` JSON DEFAULT NULL,
+ `availability` JSON DEFAULT NULL COMMENT '{"monday":true,"tuesday":true,...}',
+ `total_hours` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ `start_date` DATE DEFAULT NULL,
+ `background_check` TINYINT NOT NULL DEFAULT 0,
+ `background_check_date` DATE DEFAULT NULL,
+ `emergency_contact_name` VARCHAR(255) DEFAULT NULL,
+ `emergency_contact_phone` VARCHAR(50) DEFAULT NULL,
+ `t_shirt_size` VARCHAR(10) DEFAULT NULL,
+ `notes` TEXT,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_contact` (`contact_id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Volunteer Hours Log
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_volunteer_hours` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `volunteer_id` INT UNSIGNED NOT NULL,
+ `activity` VARCHAR(255) NOT NULL,
+ `hours` DECIMAL(5,2) NOT NULL,
+ `volunteer_date` DATE NOT NULL,
+ `supervisor` VARCHAR(255) DEFAULT NULL,
+ `notes` TEXT,
+ `approved` TINYINT NOT NULL DEFAULT 0,
+ `approved_by` INT DEFAULT NULL,
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_volunteer` (`volunteer_id`),
+ KEY `idx_date` (`volunteer_date`),
+ KEY `idx_approved` (`approved`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Memberships — dues-based membership program
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_memberships` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `contact_id` INT NOT NULL,
+ `membership_level` ENUM('basic','silver','gold','platinum','lifetime','honorary') NOT NULL DEFAULT 'basic',
+ `start_date` DATE NOT NULL,
+ `end_date` DATE DEFAULT NULL,
+ `annual_dues` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ `status` ENUM('active','expired','cancelled','pending','grace') NOT NULL DEFAULT 'pending',
+ `auto_renew` TINYINT NOT NULL DEFAULT 0,
+ `member_number` VARCHAR(50) DEFAULT NULL,
+ `joined_date` DATE DEFAULT NULL,
+ `notes` TEXT,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_contact` (`contact_id`),
+ KEY `idx_level` (`membership_level`),
+ KEY `idx_status` (`status`),
+ KEY `idx_end` (`end_date`),
+ UNIQUE KEY `idx_member_number` (`member_number`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Tax Receipts — IRS-compliant donation acknowledgments
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_tax_receipts` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `donation_id` INT UNSIGNED DEFAULT NULL,
+ `donor_id` INT UNSIGNED NOT NULL,
+ `receipt_number` VARCHAR(50) NOT NULL,
+ `tax_year` INT NOT NULL,
+ `amount` DECIMAL(15,2) NOT NULL,
+ `date_issued` DATE NOT NULL,
+ `delivery_method` ENUM('email','mail','both') NOT NULL DEFAULT 'email',
+ `sent` TINYINT NOT NULL DEFAULT 0,
+ `sent_date` DATETIME DEFAULT NULL,
+ `file_path` VARCHAR(500) DEFAULT NULL COMMENT 'PDF path',
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `idx_receipt_number` (`receipt_number`),
+ KEY `idx_donor` (`donor_id`),
+ KEY `idx_year` (`tax_year`),
+ KEY `idx_donation` (`donation_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Events — fundraisers, galas, community events
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_events` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` VARCHAR(255) NOT NULL,
+ `description` TEXT,
+ `event_type` ENUM('gala','auction','walkathon','golf','concert','dinner','community','virtual','hybrid') NOT NULL DEFAULT 'community',
+ `campaign_id` INT UNSIGNED DEFAULT NULL,
+ `venue` VARCHAR(255) DEFAULT NULL,
+ `address` TEXT,
+ `start_date` DATETIME NOT NULL,
+ `end_date` DATETIME DEFAULT NULL,
+ `ticket_price` DECIMAL(10,2) DEFAULT NULL,
+ `capacity` INT UNSIGNED DEFAULT NULL,
+ `registered` INT UNSIGNED NOT NULL DEFAULT 0,
+ `revenue_goal` DECIMAL(15,2) DEFAULT NULL,
+ `actual_revenue` DECIMAL(15,2) NOT NULL DEFAULT 0.00,
+ `status` ENUM('planning','registration_open','sold_out','in_progress','completed','cancelled') NOT NULL DEFAULT 'planning',
+ `public` TINYINT NOT NULL DEFAULT 1,
+ `image` VARCHAR(500) DEFAULT NULL,
+ `created_by` INT NOT NULL DEFAULT 0,
+ `created` DATETIME NOT NULL,
+ `modified` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_campaign` (`campaign_id`),
+ KEY `idx_type` (`event_type`),
+ KEY `idx_status` (`status`),
+ KEY `idx_date` (`start_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ============================================================
+-- Event Registrations
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `#__mokosuitenpo_event_registrations` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `event_id` INT UNSIGNED NOT NULL,
+ `contact_id` INT DEFAULT NULL,
+ `guest_name` VARCHAR(255) NOT NULL,
+ `guest_email` VARCHAR(255) DEFAULT NULL,
+ `guest_phone` VARCHAR(50) DEFAULT NULL,
+ `ticket_count` INT UNSIGNED NOT NULL DEFAULT 1,
+ `amount_paid` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ `dietary_restrictions` VARCHAR(500) DEFAULT NULL,
+ `table_assignment` VARCHAR(50) DEFAULT NULL,
+ `status` ENUM('registered','confirmed','attended','cancelled','no_show') NOT NULL DEFAULT 'registered',
+ `checked_in_at` DATETIME DEFAULT NULL,
+ `created` DATETIME NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_event` (`event_id`),
+ KEY `idx_contact` (`contact_id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/source/packages/plg_system_mokosuitenpo/sql/uninstall.mysql.sql b/source/packages/plg_system_mokosuitenpo/sql/uninstall.mysql.sql
new file mode 100644
index 0000000..1fd70c5
--- /dev/null
+++ b/source/packages/plg_system_mokosuitenpo/sql/uninstall.mysql.sql
@@ -0,0 +1,12 @@
+DROP TABLE IF EXISTS `#__mokosuitenpo_event_registrations`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_events`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_tax_receipts`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_memberships`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_volunteer_hours`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_volunteers`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_grants`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_campaigns`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_pledges`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_donations`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_funds`;
+DROP TABLE IF EXISTS `#__mokosuitenpo_donors`;
diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/CampaignHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/CampaignHelper.php
new file mode 100644
index 0000000..a987849
--- /dev/null
+++ b/source/packages/plg_system_mokosuitenpo/src/Helper/CampaignHelper.php
@@ -0,0 +1,98 @@
+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/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;
+ }
+}
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/packages/plg_task_mokosuitenpo/src/Extension/NpoAutomation.php b/source/packages/plg_task_mokosuitenpo/src/Extension/NpoAutomation.php
new file mode 100644
index 0000000..d9b3efa
--- /dev/null
+++ b/source/packages/plg_task_mokosuitenpo/src/Extension/NpoAutomation.php
@@ -0,0 +1,133 @@
+ [
+ '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']);
+ }
+}
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
+