feat: RecurringDonationHelper + review fixes #13

Merged
jmiller merged 2 commits from dev into main 2026-06-18 15:42:16 +00:00
@@ -0,0 +1,177 @@
<?php
namespace Moko\Plugin\System\MokoSuiteNpo\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Recurring donations — monthly/quarterly/annual pledges with autopay via saved payment methods.
*/
class RecurringDonationHelper
{
/**
* Create a recurring donation pledge.
*/
public static function createPledge(int $donorId, float $amount, string $frequency, int $fundId, ?int $savedPaymentId = null): int
{
$db = Factory::getContainer()->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;
}
}