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

No active campaigns.

+
+
+
+ + +
+
+
Recent Donations
+
+ + + + + + + + + + + + +
DonorAmountTypeDate
anonymous_giving ? 'Anonymous' : $this->escape($d->donor_name ?? ''); ?>$amount, 2); ?>donation_type); ?>donation_date)); ?>
+
+
+
+
+ + + +
+
Upcoming Grant Deadlines
+
+ + + + application_deadline) - time()) / 86400)); + ?> + + + + + + + + + +
GrantFunderAmountDeadlineStatus
escape($g->title); ?>escape($g->funder_name); ?>$amount_requested, 0); ?> daysstatus); ?>
+
+
+ +
diff --git a/source/packages/com_mokosuitenpo/mokosuitenpo.xml b/source/packages/com_mokosuitenpo/mokosuitenpo.xml new file mode 100644 index 0000000..4135fa0 --- /dev/null +++ b/source/packages/com_mokosuitenpo/mokosuitenpo.xml @@ -0,0 +1,21 @@ + + + MokoSuite NPO + Moko Consulting + 2026-06-11 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 01.01.00 + 8.3 + MokoSuite NPO component + Moko\Component\MokoSuiteNpo + + MokoSuite NPO + srcservicestmpl + + srctmpl + src + cssjs + diff --git a/source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php b/source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php new file mode 100644 index 0000000..1c574f0 --- /dev/null +++ b/source/packages/com_mokosuitenpo/site/src/Controller/DisplayController.php @@ -0,0 +1,11 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('c.*, f.name AS fund_name') + ->from($db->quoteName('#__mokosuitenpo_campaigns', 'c')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = c.fund_id') + ->where($db->quoteName('c.status') . ' = ' . $db->quote('active')) + ->order('c.end_date ASC')); + $campaigns = $db->loadObjectList() ?: []; + + foreach ($campaigns as &$c) { + $c->progress_pct = $c->goal_amount > 0 ? min(100, round($c->raised_amount / $c->goal_amount * 100)) : 0; + $c->remaining = max(0, (float) $c->goal_amount - (float) $c->raised_amount); + $c->days_remaining = $c->end_date ? max(0, round((strtotime($c->end_date) - time()) / 86400)) : null; + } + + return $campaigns; + } + + public static function getThermometerData(int $campaignId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_campaigns')->where('id = ' . $campaignId)); + $c = $db->loadObject(); + + if (!$c) return (object) ['goal' => 0, 'raised' => 0, 'pct' => 0, 'donors' => 0]; + + return (object) [ + 'goal' => (float) $c->goal_amount, + 'raised' => (float) $c->raised_amount, + 'pct' => $c->goal_amount > 0 ? min(100, round($c->raised_amount / $c->goal_amount * 100)) : 0, + 'donors' => (int) $c->donor_count, + 'remaining' => max(0, (float) $c->goal_amount - (float) $c->raised_amount), + 'title' => $c->title, + 'end_date' => $c->end_date, + ]; + } + + public static function getCampaignDonations(int $campaignId, int $limit = 50): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name') + ->select('don.anonymous_giving, don.recognition_name') + ->from($db->quoteName('#__mokosuitenpo_donations', 'd')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id') + ->where('d.campaign_id = ' . $campaignId) + ->order('d.donation_date DESC'), 0, $limit); + + $donations = $db->loadObjectList() ?: []; + + // Anonymize where needed + foreach ($donations as &$d) { + if ($d->anonymous_giving) { + $d->donor_name = 'Anonymous'; + } elseif ($d->recognition_name) { + $d->donor_name = $d->recognition_name; + } + } + + return $donations; + } + + public static function getFundraisingSummary(int $year = 0): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $year = $year ?: (int) date('Y'); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(DISTINCT d.donor_id) AS unique_donors') + ->select('COUNT(*) AS total_gifts') + ->select('COALESCE(SUM(d.amount), 0) AS total_raised') + ->select('COALESCE(AVG(d.amount), 0) AS avg_gift') + ->select('MAX(d.amount) AS largest_gift') + ->from($db->quoteName('#__mokosuitenpo_donations', 'd')) + ->where('YEAR(d.donation_date) = ' . $year)); + + return $db->loadObject() ?: (object) ['unique_donors' => 0, 'total_gifts' => 0, 'total_raised' => 0, 'avg_gift' => 0, 'largest_gift' => 0]; + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php new file mode 100644 index 0000000..9d1f57c --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/DonorHelper.php @@ -0,0 +1,165 @@ + ['min' => 0, 'label' => 'Prospect'], + 'first_time' => ['min' => 1, 'label' => 'First-Time Donor'], + 'repeat' => ['min' => 100, 'label' => 'Repeat Donor'], + 'major' => ['min' => 5000, 'label' => 'Major Donor'], + 'legacy' => ['min' => 50000, 'label' => 'Legacy Donor'], + ]; + + public static function getOrCreateDonor(int $contactId): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true)->select('*')->from('#__mokosuitenpo_donors') + ->where('contact_id = ' . $contactId)); + $donor = $db->loadObject(); + + if ($donor) return $donor; + + $donor = (object) [ + 'contact_id' => $contactId, + 'donor_type' => 'individual', + 'donor_level' => 'prospect', + 'lifetime_giving' => 0, + 'gift_count' => 0, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_donors', $donor, 'id'); + return $donor; + } + + public static function recordDonation(int $donorId, float $amount, int $fundId = 1, string $type = 'cash', ?int $campaignId = null, array $extra = []): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $donation = (object) [ + 'donor_id' => $donorId, + 'fund_id' => $fundId, + 'campaign_id' => $campaignId, + 'amount' => $amount, + 'donation_type' => $type, + 'donation_date' => $extra['date'] ?? date('Y-m-d'), + 'payment_method' => $extra['payment_method'] ?? null, + 'payment_reference' => $extra['reference'] ?? null, + 'is_tax_deductible' => (int) ($extra['tax_deductible'] ?? 1), + 'tribute_type' => $extra['tribute_type'] ?? null, + 'tribute_name' => $extra['tribute_name'] ?? null, + 'notes' => $extra['notes'] ?? '', + 'created' => Factory::getDate()->toSql(), + 'created_by' => Factory::getApplication()->getIdentity()->id, + ]; + + $db->insertObject('#__mokosuitenpo_donations', $donation, 'id'); + + // Update donor stats + self::updateDonorStats($donorId); + + // Update fund balance + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_funds') + ->set('current_balance = current_balance + ' . (float) $amount) + ->where('id = ' . $fundId)); + $db->execute(); + + // Update campaign raised amount + if ($campaignId) { + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_campaigns') + ->set('raised_amount = raised_amount + ' . (float) $amount) + ->set('donor_count = donor_count + 1') + ->where('id = ' . $campaignId)); + $db->execute(); + } + + return (int) $donation->id; + } + + public static function updateDonorStats(int $donorId): void + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS gift_count, COALESCE(SUM(amount), 0) AS lifetime, MAX(amount) AS largest, MIN(donation_date) AS first_date, MAX(donation_date) AS last_date') + ->from('#__mokosuitenpo_donations') + ->where('donor_id = ' . $donorId)); + $stats = $db->loadObject(); + + $lifetime = (float) ($stats->lifetime ?? 0); + $level = 'prospect'; + foreach (array_reverse(self::LEVELS) as $key => $config) { + if ($lifetime >= $config['min']) { $level = $key; break; } + } + + $db->updateObject('#__mokosuitenpo_donors', (object) [ + 'id' => $donorId, + 'lifetime_giving' => $lifetime, + 'largest_gift' => (float) ($stats->largest ?? 0), + 'first_gift_date' => $stats->first_date, + 'last_gift_date' => $stats->last_date, + 'gift_count' => (int) ($stats->gift_count ?? 0), + 'donor_level' => $level, + 'modified' => Factory::getDate()->toSql(), + ], 'id'); + } + + public static function getDonorSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_donors') + ->select('SUM(lifetime_giving) AS total_lifetime') + ->select('SUM(CASE WHEN donor_level = ' . $db->quote('major') . ' THEN 1 ELSE 0 END) AS major_donors') + ->select('SUM(CASE WHEN donor_level = ' . $db->quote('lapsed') . ' THEN 1 ELSE 0 END) AS lapsed_donors') + ->select('AVG(lifetime_giving) AS avg_lifetime') + ->from('#__mokosuitenpo_donors')); + + return $db->loadObject() ?: (object) ['total_donors' => 0, 'total_lifetime' => 0, 'major_donors' => 0, 'lapsed_donors' => 0, 'avg_lifetime' => 0]; + } + + public static function getTopDonors(int $limit = 10, string $dateFrom = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $query = $db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('d.anonymous_giving') . ' = 0') + ->order('d.lifetime_giving DESC'); + + $db->setQuery($query, 0, $limit); + return $db->loadObjectList() ?: []; + } + + public static function getLapsedDonors(int $monthsInactive = 12): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $cutoff = date('Y-m-d', strtotime("-{$monthsInactive} months")); + + $db->setQuery($db->getQuery(true) + ->select('d.*, cd.name AS donor_name, cd.email_to') + ->from($db->quoteName('#__mokosuitenpo_donors', 'd')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('d.last_gift_date') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('d.gift_count') . ' > 0') + ->order('d.last_gift_date ASC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php new file mode 100644 index 0000000..4c9adaa --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/TaxReceiptHelper.php @@ -0,0 +1,145 @@ +get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('d.*, don.contact_id, cd.name AS donor_name, cd.email_to, cd.address') + ->from($db->quoteName('#__mokosuitenpo_donations', 'd')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = d.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id') + ->where('d.id = ' . $donationId)); + $donation = $db->loadObject(); + + if (!$donation || !$donation->is_tax_deductible) return null; + + $params = Factory::getApplication()->getParams('com_mokosuitenpo'); + $prefix = $params->get('receipt_prefix', 'RCP'); + $taxYear = date('Y', strtotime($donation->donation_date)); + + $seq = (int) $db->setQuery($db->getQuery(true)->select('COUNT(*)') + ->from('#__mokosuitenpo_tax_receipts') + ->where('tax_year = ' . $taxYear))->loadResult() + 1; + + $receiptNumber = $prefix . '-' . $taxYear . '-' . str_pad($seq, 5, '0', STR_PAD_LEFT); + + $receipt = (object) [ + 'donation_id' => $donationId, + 'donor_id' => $donation->donor_id, + 'receipt_number' => $receiptNumber, + 'tax_year' => $taxYear, + 'amount' => $donation->amount, + 'date_issued' => date('Y-m-d'), + 'delivery_method'=> $donation->email_to ? 'email' : 'mail', + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_tax_receipts', $receipt, 'id'); + + // Mark donation as receipt sent + $db->updateObject('#__mokosuitenpo_donations', (object) [ + 'id' => $donationId, + 'receipt_sent' => 1, + 'receipt_sent_date' => date('Y-m-d'), + ], 'id'); + + return (int) $receipt->id; + } + + public static function sendReceipt(int $receiptId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('r.*, cd.name AS donor_name, cd.email_to, cd.address') + ->from($db->quoteName('#__mokosuitenpo_tax_receipts', 'r')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = r.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id') + ->where('r.id = ' . $receiptId)); + $receipt = $db->loadObject(); + + if (!$receipt || !$receipt->email_to) return false; + + $params = Factory::getApplication()->getParams('com_mokosuitenpo'); + $orgName = $params->get('org_name', Factory::getApplication()->get('sitename')); + $orgEin = $params->get('org_ein', ''); + $orgAddress = $params->get('org_address', ''); + + $body = "TAX RECEIPT\n" + . str_repeat('=', 50) . "\n\n" + . "Receipt Number: {$receipt->receipt_number}\n" + . "Date: " . date('F j, Y', strtotime($receipt->date_issued)) . "\n\n" + . "Organization: {$orgName}\n" + . ($orgEin ? "EIN: {$orgEin}\n" : '') + . ($orgAddress ? "Address: {$orgAddress}\n" : '') + . "\n" + . "Donor: {$receipt->donor_name}\n" + . "Donation Amount: \$" . number_format((float) $receipt->amount, 2) . "\n" + . "Tax Year: {$receipt->tax_year}\n\n" + . "No goods or services were provided in exchange for this contribution.\n\n" + . "This receipt serves as your official acknowledgment for tax purposes.\n" + . "Please retain for your records.\n\n" + . "Thank you for your generous support!\n"; + + $mailer = Factory::getMailer(); + $mailer->addRecipient($receipt->email_to, $receipt->donor_name); + $mailer->setSubject("Tax Receipt {$receipt->receipt_number} - {$orgName}"); + $mailer->setBody($body); + $result = $mailer->Send(); + + if ($result) { + $db->updateObject('#__mokosuitenpo_tax_receipts', (object) [ + 'id' => $receiptId, 'sent' => 1, 'sent_date' => Factory::getDate()->toSql(), + ], 'id'); + } + + return (bool) $result; + } + + public static function generateYearEndReceipts(int $taxYear): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('DISTINCT donor_id') + ->from('#__mokosuitenpo_donations') + ->where('YEAR(donation_date) = ' . $taxYear) + ->where('is_tax_deductible = 1') + ->where('receipt_sent = 0')); + $donorIds = $db->loadColumn() ?: []; + + $generated = 0; + foreach ($donorIds as $donorId) { + $db->setQuery($db->getQuery(true) + ->select('id') + ->from('#__mokosuitenpo_donations') + ->where('donor_id = ' . (int) $donorId) + ->where('YEAR(donation_date) = ' . $taxYear) + ->where('is_tax_deductible = 1') + ->where('receipt_sent = 0')); + $donationIds = $db->loadColumn() ?: []; + + foreach ($donationIds as $did) { + $receiptId = self::generate((int) $did); + if ($receiptId) { + self::sendReceipt($receiptId); + $generated++; + } + } + } + + return $generated; + } +} diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php new file mode 100644 index 0000000..ae44143 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/VolunteerHelper.php @@ -0,0 +1,102 @@ +get(DatabaseInterface::class); + + $volunteer = (object) [ + 'contact_id' => $contactId, + 'status' => 'active', + 'skills' => !empty($skills) ? json_encode($skills) : null, + 'availability' => !empty($availability) ? json_encode($availability) : null, + 'start_date' => date('Y-m-d'), + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_volunteers', $volunteer, 'id'); + return (int) $volunteer->id; + } + + public static function logHours(int $volunteerId, string $activity, float $hours, string $date = '', string $notes = ''): int + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $log = (object) [ + 'volunteer_id' => $volunteerId, + 'activity' => $activity, + 'hours' => $hours, + 'volunteer_date'=> $date ?: date('Y-m-d'), + 'notes' => $notes, + 'created' => Factory::getDate()->toSql(), + ]; + + $db->insertObject('#__mokosuitenpo_volunteer_hours', $log, 'id'); + + // Update total hours + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_volunteers') + ->set('total_hours = total_hours + ' . (float) $hours) + ->where('id = ' . $volunteerId)); + $db->execute(); + + return (int) $log->id; + } + + public static function getVolunteerStats(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS total_volunteers') + ->select('SUM(CASE WHEN status = ' . $db->quote('active') . ' THEN 1 ELSE 0 END) AS active') + ->select('COALESCE(SUM(total_hours), 0) AS total_hours') + ->select('COALESCE(AVG(total_hours), 0) AS avg_hours') + ->from('#__mokosuitenpo_volunteers')); + + return $db->loadObject() ?: (object) ['total_volunteers' => 0, 'active' => 0, 'total_hours' => 0, 'avg_hours' => 0]; + } + + public static function findBySkill(string $skill): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('v.*, cd.name, cd.email_to, cd.telephone') + ->from($db->quoteName('#__mokosuitenpo_volunteers', 'v')) + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->where($db->quoteName('v.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('v.skills') . ' LIKE ' . $db->quote('%' . $db->escape($skill) . '%')) + ->order('v.total_hours DESC')); + + return $db->loadObjectList() ?: []; + } + + public static function getHoursReport(string $dateFrom = '', string $dateTo = ''): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $dateFrom = $dateFrom ?: date('Y-01-01'); + $dateTo = $dateTo ?: date('Y-m-d'); + + $db->setQuery($db->getQuery(true) + ->select('cd.name AS volunteer_name, SUM(vh.hours) AS total_hours, COUNT(*) AS sessions') + ->from($db->quoteName('#__mokosuitenpo_volunteer_hours', 'vh')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_volunteers', 'v') . ' ON v.id = vh.volunteer_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = v.contact_id') + ->where('vh.volunteer_date BETWEEN ' . $db->quote($dateFrom) . ' AND ' . $db->quote($dateTo)) + ->group('vh.volunteer_id') + ->order('total_hours DESC')); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/pkg_mokosuitenpo.xml b/source/pkg_mokosuitenpo.xml new file mode 100644 index 0000000..dbef4db --- /dev/null +++ b/source/pkg_mokosuitenpo.xml @@ -0,0 +1,28 @@ + + + Package - MokoSuite NPO + mokosuitenpo + 01.01.00 + 2026-06-11 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + MokoSuite NPO - nonprofit management: donors, donations, campaigns, grants, volunteers, fund accounting. Layer 2 add-on for MokoSuite (requires CRM). + 8.3 + + true + script.php + + + plg_system_mokosuitenpo.zip + com_mokosuitenpo.zip + plg_webservices_mokosuitenpo.zip + plg_task_mokosuitenpo.zip + + + + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteNPO/updates.xml + + diff --git a/source/script.php b/source/script.php new file mode 100644 index 0000000..d76d3d4 --- /dev/null +++ b/source/script.php @@ -0,0 +1,8 @@ + + +Package - MokoSuite NPO +pkg_mokosuitenpo +package +01.01.00 + +8.3 +