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
52 changes: 52 additions & 0 deletions Civi/Paymentprocessingcore/DTO/CompletedReconcileResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Civi\Paymentprocessingcore\DTO;

/**
* Reconciliation result for completed payments where core handles completion.
*
* Extends ReconcileAttemptResult to provide CompletionData for core's
* ContributionCompletionService. Handlers return this subclass when they
* want core to complete the contribution; returning the base class with
* status 'completed' signals that the handler completed it itself.
*
* Design: transactionId is non-nullable — if you create a CompletedReconcileResult,
* you must provide a transaction ID. Core calls getCompletionData() polymorphically:
* returns CompletionData from this subclass, NULL from the base class.
*/
class CompletedReconcileResult extends ReconcileAttemptResult {

/**
* Completion data for core processing.
*
* @var \Civi\Paymentprocessingcore\DTO\CompletionData
*/
private CompletionData $completionData;

/**
* Constructor.
*
* @param string $actionTaken
* Human-readable description of what happened (e.g., 'PaymentIntent succeeded').
* @param string $transactionId
* Payment processor transaction ID (e.g., Stripe charge ID ch_...).
* @param float|null $feeAmount
* Optional fee amount charged by payment processor.
*/
public function __construct(
string $actionTaken,
string $transactionId,
?float $feeAmount = NULL,
) {
parent::__construct('completed', $actionTaken);
$this->completionData = new CompletionData($transactionId, $feeAmount);
}

/**
* {@inheritdoc}
*/
public function getCompletionData(): CompletionData {
return $this->completionData;
}

}
26 changes: 26 additions & 0 deletions Civi/Paymentprocessingcore/DTO/CompletionData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Civi\Paymentprocessingcore\DTO;

/**
* Immutable Value Object grouping contribution completion details.
*
* Used by CompletedReconcileResult to provide transaction details
* for core's ContributionCompletionService.
*/
class CompletionData {

/**
* Constructor.
*
* @param string $transactionId
* Payment processor transaction ID (e.g., Stripe charge ID ch_...).
* @param float|null $feeAmount
* Optional fee amount charged by payment processor.
*/
public function __construct(
public readonly string $transactionId,
public readonly ?float $feeAmount = NULL,
) {}

}
60 changes: 60 additions & 0 deletions Civi/Paymentprocessingcore/DTO/ReconcileAttemptResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Civi\Paymentprocessingcore\DTO;

/**
* Value Object representing the result of reconciling a single payment attempt.
*
* Immutable after construction. Validates status against allowed values.
* Used by processor-specific handlers to report reconciliation outcomes
* back to the core ReconcilePaymentAttemptBatchEvent.
*
* @phpstan-type ValidStatus 'completed'|'failed'|'cancelled'|'unchanged'
*/
class ReconcileAttemptResult {

/**
* Valid reconciliation statuses.
*
* @var array<int, string>
*/
private const VALID_STATUSES = ['completed', 'failed', 'cancelled', 'unchanged'];

/**
* Constructor.
*
* @param string $status
* Reconciliation outcome: completed, failed, cancelled, or unchanged.
* @param string $actionTaken
* Human-readable description of what happened (e.g., 'PaymentIntent succeeded').
*
* @phpstan-param ValidStatus $status
*
* @throws \InvalidArgumentException
* If status is not one of the valid values.
*/
public function __construct(
public readonly string $status,
public readonly string $actionTaken,
) {
if (!in_array($status, self::VALID_STATUSES, TRUE)) {
throw new \InvalidArgumentException(
sprintf('Invalid status "%s". Valid: %s', $status, implode(', ', self::VALID_STATUSES))
);
}
}

/**
* Returns completion data if core should complete the contribution.
*
* Base implementation returns NULL (opt-out). Subclasses override
* to provide completion data for core processing.
*
* @return \Civi\Paymentprocessingcore\DTO\CompletionData|null
* Completion data or NULL if handler manages completion itself.
*/
public function getCompletionData(): ?CompletionData {
return NULL;
}

}
154 changes: 154 additions & 0 deletions Civi/Paymentprocessingcore/Event/ReconcilePaymentAttemptBatchEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

namespace Civi\Paymentprocessingcore\Event;

use Civi\Core\Event\GenericHookEvent;
use Civi\Paymentprocessingcore\DTO\ReconcileAttemptResult;

/**
* Batch event for reconciling stuck payment attempts.
*
* Dispatched once per processor type. Allows processor extensions to check
* their API for the real status of stuck payments and report results.
*
* Two usage patterns:
* - Stripe: Uses getAttempts() to iterate PaymentAttempt records, calls
* setAttemptResult() for each.
* - GoCardless: Uses getProcessorType()/getThresholdDays()/getRemainingBudget()
* as trigger + config; queries its own data internally.
*/
class ReconcilePaymentAttemptBatchEvent extends GenericHookEvent {

/**
* Event name constant.
*/
public const NAME = 'paymentprocessingcore.reconcile_payment_attempt_batch';

/**
* Reconciliation results keyed by attempt ID.
*
* @var array<int, \Civi\Paymentprocessingcore\DTO\ReconcileAttemptResult>
*/
private array $results = [];

/**
* Constructor.
*
* @param string $processorType
* Processor type name (e.g., 'Stripe', 'GoCardless').
* @param array $attempts
* Array of stuck PaymentAttempt records, keyed by attempt ID.
* @param int $thresholdDays
* Number of days a payment must be stuck before reconciliation.
* @param int $remainingBudget
* Remaining batch budget available for this processor.
* @param int $maxRetryCount
* Maximum number of retries before marking a recurring contribution as failed.
*
* @phpstan-param array<int, array<string, mixed>> $attempts
*/
public function __construct(
protected string $processorType,
protected array $attempts,
protected int $thresholdDays,
protected int $remainingBudget,
protected int $maxRetryCount = 3,
) {}

/**
* Get the processor type.
*
* @return string
* The processor type name.
*/
public function getProcessorType(): string {
return $this->processorType;
}

/**
* Get the stuck payment attempts.
*
* @return array
* Array of PaymentAttempt records, keyed by attempt ID.
*
* @phpstan-return array<int, array<string, mixed>>
*/
public function getAttempts(): array {
return $this->attempts;
}

/**
* Get the threshold days for stuck detection.
*
* @return int
* Number of days before an attempt is considered stuck.
*/
public function getThresholdDays(): int {
return $this->thresholdDays;
}

/**
* Get the remaining batch budget.
*
* @return int
* Remaining number of items that can be processed.
*/
public function getRemainingBudget(): int {
return $this->remainingBudget;
}

/**
* Get the maximum retry count.
*
* @return int
* Maximum number of retries before marking a recurring contribution as failed.
*/
public function getMaxRetryCount(): int {
return $this->maxRetryCount;
}

/**
* Set the reconciliation result for a specific attempt.
*
* @param int $attemptId
* The PaymentAttempt ID.
* @param \Civi\Paymentprocessingcore\DTO\ReconcileAttemptResult $result
* The reconciliation result.
*
* @throws \InvalidArgumentException
* If the attempt ID is not in the attempts array.
*/
public function setAttemptResult(int $attemptId, ReconcileAttemptResult $result): void {
if (!array_key_exists($attemptId, $this->attempts)) {
throw new \InvalidArgumentException(
sprintf('Attempt ID %d is not in the attempts array', $attemptId)
);
}

$this->results[$attemptId] = $result;
}

/**
* Get all reconciliation results.
*
* @return array<int, \Civi\Paymentprocessingcore\DTO\ReconcileAttemptResult>
* Results keyed by attempt ID.
*/
public function getAttemptResults(): array {
return $this->results;
}

/**
* Check whether a result has been set for a specific attempt.
*
* @param int $attemptId
* The PaymentAttempt ID.
*
* @return bool
* TRUE if a result exists for this attempt.
*/
public function hasAttemptResult(int $attemptId): bool {
return array_key_exists($attemptId, $this->results);
}

}
11 changes: 11 additions & 0 deletions Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ public function register(): void {
'Civi\Paymentprocessingcore\Service\InstalmentChargeService',
'paymentprocessingcore.instalment_charge'
);

// Register PaymentAttemptReconcileService
$this->container->setDefinition(
'paymentprocessingcore.payment_attempt_reconcile',
new Definition(\Civi\Paymentprocessingcore\Service\PaymentAttemptReconcileService::class)
)->setAutowired(TRUE)->setPublic(TRUE);

$this->container->setAlias(
'Civi\Paymentprocessingcore\Service\PaymentAttemptReconcileService',
'paymentprocessingcore.payment_attempt_reconcile'
);
}

}
Loading