diff --git a/source/packages/com_mokosuitenpo/admin/access.xml b/source/packages/com_mokosuitenpo/admin/access.xml
new file mode 100644
index 0000000..352c7ca
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/admin/access.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/source/packages/com_mokosuitenpo/admin/config.xml b/source/packages/com_mokosuitenpo/admin/config.xml
new file mode 100644
index 0000000..dc814a6
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/admin/config.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/source/packages/com_mokosuitenpo/site/src/Service/Router.php b/source/packages/com_mokosuitenpo/site/src/Service/Router.php
new file mode 100644
index 0000000..e3c5688
--- /dev/null
+++ b/source/packages/com_mokosuitenpo/site/src/Service/Router.php
@@ -0,0 +1,21 @@
+ [
+ 'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_RECEIPTS_YEAREND',
+ 'method' => 'generateYearEndReceipts',
+ ],
+ 'mokosuite.npo.donors.lapsed' => [
+ 'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_DONORS_LAPSED',
+ 'method' => 'detectLapsedDonors',
+ ],
+ 'mokosuite.npo.grants.reminders' => [
+ 'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_GRANTS_REMINDERS',
+ 'method' => 'sendGrantReminders',
+ ],
+ 'mokosuite.npo.pledges.followup' => [
+ 'langConstPrefix' => 'PLG_TASK_MOKOSUITENPO_PLEDGES_FOLLOWUP',
+ 'method' => 'followUpPledges',
+ ],
+ ];
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'onExecuteTask' => 'standardRoutineHandler',
+ 'onContentPrepareForm' => 'enhanceTaskItemForm',
+ 'onTaskOptionsList' => 'advertiseRoutines',
+ ];
+ }
+
+ private function generateYearEndReceipts(ExecuteTaskEvent $event): int
+ {
+ $count = \Moko\Plugin\System\MokoSuiteNpo\Helper\TaxReceiptHelper::generateYearEndReceipts((int) date('Y') - 1);
+ Log::add("NPO year-end receipts: generated {$count}", Log::INFO, 'mokosuite.npo');
+ return Status::OK;
+ }
+
+ private function detectLapsedDonors(ExecuteTaskEvent $event): int
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->update('#__mokosuitenpo_donors')
+ ->set($db->quoteName('donor_level') . ' = ' . $db->quote('lapsed'))
+ ->where($db->quoteName('last_gift_date') . ' < DATE_SUB(NOW(), INTERVAL 12 MONTH)')
+ ->where($db->quoteName('gift_count') . ' > 0')
+ ->where($db->quoteName('donor_level') . ' != ' . $db->quote('lapsed')));
+ $db->execute();
+
+ $lapsed = $db->getAffectedRows();
+ if ($lapsed > 0) {
+ Log::add("NPO lapsed donors: {$lapsed} marked as lapsed", Log::INFO, 'mokosuite.npo');
+ }
+ return Status::OK;
+ }
+
+ private function sendGrantReminders(ExecuteTaskEvent $event): int
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('*')
+ ->from('#__mokosuitenpo_grants')
+ ->where($db->quoteName('status') . ' IN (' . $db->quote('prospect') . ',' . $db->quote('writing') . ')')
+ ->where($db->quoteName('application_deadline') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY)'));
+ $grants = $db->loadObjectList() ?: [];
+
+ if (!empty($grants)) {
+ $body = "Grant deadlines approaching:\n\n";
+ foreach ($grants as $g) {
+ $days = max(0, round((strtotime($g->application_deadline) - time()) / 86400));
+ $body .= "- {$g->title} ({$g->funder_name}) — {$days} days left\n";
+ }
+
+ $mailer = Factory::getMailer();
+ $mailer->addRecipient(Factory::getApplication()->get('mailfrom'));
+ $mailer->setSubject('NPO: ' . count($grants) . ' grant deadlines approaching');
+ $mailer->setBody($body);
+ $mailer->Send();
+ }
+
+ return Status::OK;
+ }
+
+ private function followUpPledges(ExecuteTaskEvent $event): int
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $db->setQuery($db->getQuery(true)
+ ->select('p.*, cd.name AS donor_name, cd.email_to')
+ ->from($db->quoteName('#__mokosuitenpo_pledges', 'p'))
+ ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'don') . ' ON don.id = p.donor_id')
+ ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = don.contact_id')
+ ->where($db->quoteName('p.status') . ' = ' . $db->quote('active'))
+ ->where($db->quoteName('p.due_date') . ' BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY)'));
+ $pledges = $db->loadObjectList() ?: [];
+
+ $sent = 0;
+ foreach ($pledges as $p) {
+ if (!$p->email_to) continue;
+ $remaining = (float) $p->total_amount - (float) $p->amount_fulfilled;
+
+ $mailer = Factory::getMailer();
+ $mailer->addRecipient($p->email_to, $p->donor_name);
+ $mailer->setSubject('Pledge reminder — $' . number_format($remaining, 2) . ' remaining');
+ $mailer->setBody("Hi {$p->donor_name},\n\nThis is a friendly reminder about your pledge of \${$p->total_amount}. Your remaining balance is \${$remaining}.\n\nThank you for your generosity!");
+ $mailer->Send();
+ $sent++;
+ }
+
+ Log::add("NPO pledge follow-up: sent {$sent} reminders", Log::INFO, 'mokosuite.npo');
+ return Status::OK;
+ }
+}
diff --git a/source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php b/source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php
new file mode 100644
index 0000000..d7d0764
--- /dev/null
+++ b/source/packages/plg_webservices_mokosuitenpo/src/Extension/MokoSuiteNpoApi.php
@@ -0,0 +1,27 @@
+ 'onBeforeApiRoute'];
+ }
+
+ public function onBeforeApiRoute(BeforeApiRouteEvent $event): void
+ {
+ $router = $event->getRouter();
+ $router->createCRUDRoutes('v1/mokosuite/npo/donors', 'npodonors', ['component' => 'com_mokosuitenpo']);
+ $router->createCRUDRoutes('v1/mokosuite/npo/donations', 'npodonations', ['component' => 'com_mokosuitenpo']);
+ $router->createCRUDRoutes('v1/mokosuite/npo/campaigns', 'npocampaigns', ['component' => 'com_mokosuitenpo']);
+ $router->createCRUDRoutes('v1/mokosuite/npo/grants', 'npogrants', ['component' => 'com_mokosuitenpo']);
+ $router->createCRUDRoutes('v1/mokosuite/npo/volunteers', 'npovolunteers', ['component' => 'com_mokosuitenpo']);
+ $router->createCRUDRoutes('v1/mokosuite/npo/events', 'npoevents', ['component' => 'com_mokosuitenpo']);
+ }
+}