feat: RecurringDonationHelper + review fixes #13
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user