From 990aa8668230a1183282979592be2c21dabed5e7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 18 Jun 2026 10:26:51 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Add=20RecurringDonationHelper=20=E2=80=94?= =?UTF-8?q?=20pledge=20creation,=20autopay=20processing,=20recurring=20rev?= =?UTF-8?q?enue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Helper/RecurringDonationHelper.php | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php 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..21c6081 --- /dev/null +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php @@ -0,0 +1,167 @@ +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')); + + $duePledges = $db->loadObjectList() ?: []; + + foreach ($duePledges as $pledge) { + $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; + } +} -- 2.52.0 From 42161e3d0f47764b63cba93d920efff3c69f3532 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 18 Jun 2026 10:37:52 -0500 Subject: [PATCH 2/2] fix: add idempotency guard to recurring donation processing --- .../src/Helper/RecurringDonationHelper.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php b/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php index 21c6081..ec3a1fe 100644 --- a/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php +++ b/source/packages/plg_system_mokosuitenpo/src/Helper/RecurringDonationHelper.php @@ -57,11 +57,21 @@ class RecurringDonationHelper ->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.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 -- 2.52.0