Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CRM/Paymentprocessingcore/Upgrader.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,23 @@
*/
class CRM_Paymentprocessingcore_Upgrader extends CRM_Extension_Upgrader_Base {

/**
* Backfill the payment processor on payments this extension completed without one.
*
* Until now ContributionCompletionService did not pass the payment processor to
* Contribution.completetransaction, so core left payment_processor_id empty on the financial
* transaction it created, and Finance Extras would not offer a refund for those payments.
*
* @return bool
*/
public function upgrade_1001(): bool {
\Civi::log()->info('Payment Processing Core upgrade 1001: backfilling the payment processor on payment transactions');

$backfilled = (new CRM_Paymentprocessingcore_Upgrader_PaymentProcessorBackfill())->run();

\Civi::log()->info("Payment Processing Core upgrade 1001: payment transactions given a payment processor: {$backfilled}");

return TRUE;
}

}
77 changes: 77 additions & 0 deletions CRM/Paymentprocessingcore/Upgrader/PaymentProcessorBackfill.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

use CRM_Paymentprocessingcore_ExtensionUtil as E;

/**
* Repairs payment transactions that were recorded without the payment processor that took them.
*
* Contributions completed by this extension before the processor was passed to
* Contribution.completetransaction were left with an empty payment_processor_id on their financial
* transaction. Anything that reads the processor back off the transaction - Finance Extras refunds
* in particular - therefore treats those payments as not refundable.
*
* The payment attempt recorded alongside each payment says which processor took it, so the historic
* transactions can be repaired from it.
*/
class CRM_Paymentprocessingcore_Upgrader_PaymentProcessorBackfill {

/**
* Payment transactions joined to the payment attempt that says which processor took the payment.
*
* The join relies on contribution_id being unique on civicrm_payment_attempt, which is what makes
* the attempt for a contribution unambiguous. Should that index ever be dropped, this needs a
* deterministic way to choose between a contribution's attempts.
*/
private const TABLES = '
civicrm_financial_trxn ft
INNER JOIN civicrm_entity_financial_trxn eft
ON eft.financial_trxn_id = ft.id
AND eft.entity_table = "civicrm_contribution"
INNER JOIN civicrm_payment_attempt attempt
ON attempt.contribution_id = eft.entity_id
AND attempt.payment_processor_id IS NOT NULL
';

/**
* Restricts the backfill to payment transactions that are still missing their processor.
*/
private const CONDITION = '
WHERE ft.payment_processor_id IS NULL
AND ft.is_payment = 1
';

/**
* Record the payment processor on every payment transaction that is missing it.
*
* Only transactions with no processor are touched, so this is safe to run more than once.
*
* @return int How many payment transactions were given a processor
*/
public function run(): int {
$missingBefore = $this->countTransactionsMissingProcessor();

if ($missingBefore === 0) {
return 0;
}

CRM_Core_DAO::executeQuery(
'UPDATE ' . self::TABLES
. ' SET ft.payment_processor_id = attempt.payment_processor_id '
. self::CONDITION
);

return $missingBefore - $this->countTransactionsMissingProcessor();
}

/**
* How many payment transactions could still take a processor from a payment attempt.
*
* @return int
*/
public function countTransactionsMissingProcessor(): int {
return (int) CRM_Core_DAO::singleValueQuery(
'SELECT COUNT(*) FROM ' . self::TABLES . self::CONDITION
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Civi\Api4\Contribution;
use Civi\Api4\ContributionPage;
use Civi\Api4\PaymentAttempt;
use Civi\Paymentprocessingcore\Exception\ContributionCompletionException;

/**
Expand All @@ -26,14 +27,17 @@ class ContributionCompletionService {
* @param string $transactionId Payment processor transaction ID (e.g., Stripe charge ID ch_..., GoCardless payment ID pm_...)
* @param float|null $feeAmount Optional fee amount charged by payment processor
* @param bool|null $sendReceipt Whether to send email receipt. If NULL, will check contribution page settings. Default: NULL
* @param int|null $paymentProcessorId Payment processor that took the payment. If NULL, it is resolved from the
* contribution's payment attempts or its recurring contribution. Recording it on the financial transaction is what
* allows downstream features (for example Finance Extras refunds) to know which processor to refund through.
*
* @return array<string, mixed> Completion result with keys: 'success' => TRUE, 'contribution_id' => int, 'already_completed' => bool
*
* @phpstan-return array{success: true, contribution_id: int, already_completed: bool}
*
* @throws \Civi\Paymentprocessingcore\Exception\ContributionCompletionException If completion fails
*/
public function complete(int $contributionId, string $transactionId, ?float $feeAmount = NULL, ?bool $sendReceipt = NULL): array {
public function complete(int $contributionId, string $transactionId, ?float $feeAmount = NULL, ?bool $sendReceipt = NULL, ?int $paymentProcessorId = NULL): array {
$contribution = $this->getContribution($contributionId);

// Check if already completed (idempotency)
Expand All @@ -58,8 +62,12 @@ public function complete(int $contributionId, string $transactionId, ?float $fee
$sendReceipt = $this->shouldSendReceipt($contribution);
}

if ($paymentProcessorId === NULL) {
$paymentProcessorId = $this->resolvePaymentProcessorId($contribution);
}

// Complete the transaction
$this->completeTransaction($contribution, $transactionId, $feeAmount, $sendReceipt);
$this->completeTransaction($contribution, $transactionId, $feeAmount, $sendReceipt, $paymentProcessorId);

return [
'success' => TRUE,
Expand All @@ -80,7 +88,7 @@ public function complete(int $contributionId, string $transactionId, ?float $fee
private function getContribution(int $contributionId): array {
try {
$contribution = Contribution::get(FALSE)
->addSelect('id', 'contribution_status_id:name', 'total_amount', 'currency', 'contribution_page_id', 'trxn_id')
->addSelect('id', 'contribution_status_id:name', 'total_amount', 'currency', 'contribution_page_id', 'trxn_id', 'contribution_recur_id.payment_processor_id')
->addWhere('id', '=', $contributionId)
->execute()
->first();
Expand All @@ -106,6 +114,61 @@ private function getContribution(int $contributionId): array {
}
}

/**
* Work out which payment processor took the payment.
*
* Callers that know the processor should pass it in. When they do not, the payment attempt recorded for the
* contribution is the most reliable source, since every processor that uses this service records one. Recurring
* contributions carry the processor themselves, so they are used as a second source.
*
* A contribution can only have one payment attempt - contribution_id is unique on the table.
*
* Resolution never blocks completion: if it fails, the contribution still completes, only without the processor
* recorded on the financial transaction.
*
* @param array $contribution Contribution data
*
* @phpstan-param array<string, mixed> $contribution
*
* @return int|null Payment processor ID, or NULL when it cannot be determined
*/
private function resolvePaymentProcessorId(array $contribution): ?int {
try {
$attempt = PaymentAttempt::get(FALSE)
->addSelect('payment_processor_id')
->addWhere('contribution_id', '=', $contribution['id'])
->addWhere('payment_processor_id', 'IS NOT NULL')
->addOrderBy('id', 'DESC')
->setLimit(1)
->execute()
->first();

$attemptProcessorId = is_array($attempt) ? ($attempt['payment_processor_id'] ?? NULL) : NULL;
if (is_numeric($attemptProcessorId)) {
return (int) $attemptProcessorId;
}
}
catch (\Exception $e) {
\Civi::log()->warning('ContributionCompletionService: Failed to resolve payment processor from payment attempts', [
'contribution_id' => $contribution['id'],
'error' => $e->getMessage(),
]);
}

$recurProcessorId = $contribution['contribution_recur_id.payment_processor_id'] ?? NULL;
if (is_numeric($recurProcessorId)) {
return (int) $recurProcessorId;
}

// Back office payments have neither a payment attempt nor a recurring contribution, so this is
// an ordinary outcome rather than something to flag.
\Civi::log()->info('ContributionCompletionService: No payment processor to record against the contribution', [
'contribution_id' => $contribution['id'],
]);

return NULL;
}

/**
* Check if contribution is already completed (idempotency).
*
Expand Down Expand Up @@ -183,12 +246,13 @@ private function shouldSendReceipt(array $contribution): bool {
* @param string $transactionId Payment processor transaction ID
* @param float|null $feeAmount Optional fee amount
* @param bool $sendReceipt Whether to send email receipt
* @param int|null $paymentProcessorId Payment processor that took the payment, recorded on the financial transaction
*
* @return void
*
* @throws \Civi\Paymentprocessingcore\Exception\ContributionCompletionException If completion fails
*/
private function completeTransaction(array $contribution, string $transactionId, ?float $feeAmount, bool $sendReceipt): void {
private function completeTransaction(array $contribution, string $transactionId, ?float $feeAmount, bool $sendReceipt, ?int $paymentProcessorId = NULL): void {
try {
$params = [
'id' => $contribution['id'],
Expand All @@ -201,6 +265,12 @@ private function completeTransaction(array $contribution, string $transactionId,
$params['fee_amount'] = $feeAmount;
}

// Core records this against the financial transaction it creates, which is how refunds know
// which processor to go back through. Without it the transaction is left with no processor.
if ($paymentProcessorId !== NULL) {
$params['payment_processor_id'] = $paymentProcessorId;
}

civicrm_api3('Contribution', 'completetransaction', $params);

\Civi::log()->info('ContributionCompletionService: Contribution completed successfully', [
Expand All @@ -210,6 +280,7 @@ private function completeTransaction(array $contribution, string $transactionId,
'amount' => $contribution['total_amount'],
'currency' => $contribution['currency'],
'receipt_sent' => $sendReceipt,
'payment_processor_id' => $paymentProcessorId,
]);
}
catch (\CiviCRM_API3_Exception $e) {
Expand Down
10 changes: 10 additions & 0 deletions stubs/CiviApi4.stub.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ class PaymentToken {

}

/**
* @method static DAOGetAction get(bool $checkPermissions = TRUE)
* @method static DAOCreateAction create(bool $checkPermissions = TRUE)
* @method static DAOUpdateAction update(bool $checkPermissions = TRUE)
* @method static DAODeleteAction delete(bool $checkPermissions = TRUE)
*/
class EntityFinancialTrxn {

}

/**
* @method static DAOGetAction get(bool $checkPermissions = TRUE)
* @method static DAOCreateAction create(bool $checkPermissions = TRUE)
Expand Down
Loading
Loading