diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php new file mode 100644 index 0000000..ec3a1fe --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php @@ -0,0 +1,177 @@ +get(DatabaseInterface::class); + $now = Factory::getDate()->toSql(); + + $nextDate = match ($frequency) { + 'weekly' => date('Y-m-d', strtotime('+1 week')), + 'monthly' => date('Y-m-d', strtotime('+1 month')), + 'quarterly' => date('Y-m-d', strtotime('+3 months')), + 'annually' => date('Y-m-d', strtotime('+1 year')), + default => date('Y-m-d', strtotime('+1 month')), + }; + + $pledge = (object) [ + 'donor_id' => $donorId, + 'amount' => $amount, + 'frequency' => $frequency, + 'fund_id' => $fundId, + 'saved_payment_id' => $savedPaymentId, + 'next_charge_date' => $nextDate, + 'status' => 'active', + 'created' => $now, + ]; + + $db->insertObject('#__mokosuitenpo_pledges', $pledge, 'id'); + return (int) $pledge->id; + } + + /** + * Process due recurring donations. Called by task scheduler. + */ + public static function processDueRecurring(): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $results = ['processed' => 0, 'succeeded' => 0, 'failed' => 0]; + + $db->setQuery($db->getQuery(true) + ->select('p.*, d.contact_id, cd.name AS donor_name') + ->from($db->quoteName('#__mokosuitenpo_pledges', 'p')) + ->join('INNER', $db->quoteName('#__mokosuitenpo_donors', 'd') . ' ON d.id = p.donor_id') + ->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id') + ->where($db->quoteName('p.status') . ' = ' . $db->quote('active')) + ->where($db->quoteName('p.next_charge_date') . ' <= CURDATE()') + ->where('p.saved_payment_id IS NOT NULL AND p.saved_payment_id > 0') + ->where('(p.last_charged_at IS NULL OR DATE(p.last_charged_at) < CURDATE())')); + + $duePledges = $db->loadObjectList() ?: []; + + foreach ($duePledges as $pledge) { + // Idempotency: claim this pledge before charging to prevent double-processing + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_pledges') + ->set($db->quoteName('last_charged_at') . ' = NOW()') + ->where('id = ' . (int) $pledge->id) + ->where('(last_charged_at IS NULL OR DATE(last_charged_at) < CURDATE())')); + $db->execute(); + if ($db->getAffectedRows() === 0) continue; // Already claimed + + $results['processed']++; + + // Charge via saved payment + $chargeResult = \Moko\Plugin\System\MokoSuiteErp\Helper\SavedPaymentHelper::chargeToken( + (int) $pledge->saved_payment_id, + (float) $pledge->amount, + 'USD', + 'Recurring donation: ' . ($pledge->donor_name ?? 'Donor #' . $pledge->donor_id) + ); + + if ($chargeResult->success) { + $results['succeeded']++; + + // Record donation + DonorHelper::recordDonation( + (int) $pledge->donor_id, + (float) $pledge->amount, + 'card', + (int) $pledge->fund_id, + 0, + 'Recurring pledge #' . $pledge->id + ); + + // Advance next charge date + $nextDate = match ($pledge->frequency) { + 'weekly' => date('Y-m-d', strtotime($pledge->next_charge_date . ' +1 week')), + 'monthly' => date('Y-m-d', strtotime($pledge->next_charge_date . ' +1 month')), + 'quarterly' => date('Y-m-d', strtotime($pledge->next_charge_date . ' +3 months')), + 'annually' => date('Y-m-d', strtotime($pledge->next_charge_date . ' +1 year')), + default => date('Y-m-d', strtotime($pledge->next_charge_date . ' +1 month')), + }; + + $db->setQuery($db->getQuery(true) + ->update('#__mokosuitenpo_pledges') + ->set($db->quoteName('next_charge_date') . ' = ' . $db->quote($nextDate)) + ->set($db->quoteName('last_charged_at') . ' = ' . $db->quote(Factory::getDate()->toSql())) + ->where('id = ' . (int) $pledge->id)); + $db->execute(); + } else { + $results['failed']++; + } + } + + return $results; + } + + /** + * Cancel a recurring pledge. + */ + public static function cancelPledge(int $pledgeId): bool + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $update = (object) [ + 'id' => $pledgeId, + 'status' => 'cancelled', + 'cancelled_at' => Factory::getDate()->toSql(), + ]; + + return $db->updateObject('#__mokosuitenpo_pledges', $update, 'id'); + } + + /** + * Get active recurring pledges for a donor. + */ + public static function getDonorPledges(int $donorId): array + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('p.*, f.name AS fund_name') + ->from($db->quoteName('#__mokosuitenpo_pledges', 'p')) + ->join('LEFT', $db->quoteName('#__mokosuitenpo_funds', 'f') . ' ON f.id = p.fund_id') + ->where('p.donor_id = ' . (int) $donorId) + ->where($db->quoteName('p.status') . ' = ' . $db->quote('active')) + ->order('p.next_charge_date ASC')); + + return $db->loadObjectList() ?: []; + } + + /** + * Get recurring revenue summary. + */ + public static function getRecurringSummary(): object + { + $db = Factory::getContainer()->get(DatabaseInterface::class); + + $db->setQuery($db->getQuery(true) + ->select('COUNT(*) AS active_pledges') + ->select('COALESCE(SUM(amount), 0) AS total_pledge_amount') + ->select('COALESCE(SUM(CASE WHEN frequency = ' . $db->quote('monthly') . ' THEN amount ELSE 0 END), 0) AS monthly_recurring') + ->select('COALESCE(SUM(CASE WHEN frequency = ' . $db->quote('annually') . ' THEN amount ELSE 0 END), 0) AS annual_recurring') + ->from('#__mokosuitenpo_pledges') + ->where($db->quoteName('status') . ' = ' . $db->quote('active'))); + + $summary = $db->loadObject() ?: (object) ['active_pledges' => 0, 'total_pledge_amount' => 0, 'monthly_recurring' => 0, 'annual_recurring' => 0]; + + // Annualize for comparison + $summary->annualized_revenue = (float) $summary->monthly_recurring * 12 + (float) $summary->annual_recurring; + + return $summary; + } +}