From 585e971c1b94167c7d29397bd12a7a8e16597c12 Mon Sep 17 00:00:00 2001 From: Erawat Chamanont Date: Thu, 22 Jan 2026 09:19:38 +0000 Subject: [PATCH] DRUPALMM-207: Add ContributionPayability API for generic payability checks Add a generic payability API that works across multiple payment processors using a registry/provider pattern similar to the existing WebhookHandlerRegistry. New components: - PayabilityProviderInterface: Contract for processor-specific providers - PayabilityResult: DTO for payability check results - PayabilityProviderAdapter: Adapter for duck-typed providers (autoload safety) - PayabilityProviderRegistry: Service to register and lookup providers - ContributionPayability.getStatus: API4 action to check payability The API allows payment processor extensions to register their payability providers, which determine whether contributions can be paid now or are managed by the processor (subscriptions, payment plans, etc.). Example usage: ContributionPayability.getStatus(contactId=123, contributionStatus=['Pending']) --- .../ContributionPayability/GetStatus.php | 214 ++++++++++++++++++ Civi/Api4/ContributionPayability.php | 128 +++++++++++ .../Hook/Container/ServiceContainer.php | 11 + .../Payability/PayabilityProviderAdapter.php | 49 ++++ .../PayabilityProviderInterface.php | 77 +++++++ .../Payability/PayabilityResult.php | 142 ++++++++++++ .../Service/PayabilityProviderRegistry.php | 142 ++++++++++++ .../Payability/PayabilityResultTest.php | 141 ++++++++++++ .../PayabilityProviderRegistryTest.php | 203 +++++++++++++++++ 9 files changed, 1107 insertions(+) create mode 100644 Civi/Api4/Action/ContributionPayability/GetStatus.php create mode 100644 Civi/Api4/ContributionPayability.php create mode 100644 Civi/Paymentprocessingcore/Payability/PayabilityProviderAdapter.php create mode 100644 Civi/Paymentprocessingcore/Payability/PayabilityProviderInterface.php create mode 100644 Civi/Paymentprocessingcore/Payability/PayabilityResult.php create mode 100644 Civi/Paymentprocessingcore/Service/PayabilityProviderRegistry.php create mode 100644 tests/phpunit/Civi/Paymentprocessingcore/Payability/PayabilityResultTest.php create mode 100644 tests/phpunit/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistryTest.php diff --git a/Civi/Api4/Action/ContributionPayability/GetStatus.php b/Civi/Api4/Action/ContributionPayability/GetStatus.php new file mode 100644 index 0000000..f82b900 --- /dev/null +++ b/Civi/Api4/Action/ContributionPayability/GetStatus.php @@ -0,0 +1,214 @@ +loadContributions(); + + if (empty($contributions)) { + return; + } + + // Group contributions by payment processor type + $groupedByProcessor = $this->groupByProcessorType($contributions); + + // Get payability registry + $registry = $this->getPayabilityRegistry(); + + // Process each processor type + $payabilityResults = []; + foreach ($groupedByProcessor as $processorType => $contributionIds) { + if ($registry->hasProvider($processorType)) { + $provider = $registry->getProvider($processorType); + $providerResults = $provider->getPayabilityForContributions($contributionIds); + $payabilityResults = array_merge($payabilityResults, $providerResults); + } + } + + // Build final result set + foreach ($contributions as $contribution) { + $contributionId = (int) $contribution['id']; + $processorType = $contribution['payment_processor_type'] ?? NULL; + + // Base contribution data + $row = [ + 'id' => $contributionId, + 'contact_id' => (int) $contribution['contact_id'], + 'total_amount' => $contribution['total_amount'], + 'currency' => $contribution['currency'], + 'receive_date' => $contribution['receive_date'], + 'contribution_status' => $contribution['contribution_status_id:name'], + 'payment_processor_type' => $processorType, + ]; + + // Add payability info + if (isset($payabilityResults[$contributionId])) { + $payability = $payabilityResults[$contributionId]; + if ($payability instanceof PayabilityResult) { + $row = array_merge($row, $payability->toArray()); + } + else { + // Handle array format from duck-typed providers + $row['can_pay_now'] = $payability['can_pay_now'] ?? NULL; + $row['payability_reason'] = $payability['payability_reason'] ?? NULL; + $row['payment_type'] = $payability['payment_type'] ?? NULL; + $row['payability_metadata'] = $payability['payability_metadata'] ?? []; + } + } + else { + // No provider registered for this processor type + $row['can_pay_now'] = NULL; + $row['payability_reason'] = $processorType + ? "No payability provider registered for processor type: {$processorType}" + : 'No payment processor associated'; + $row['payment_type'] = NULL; + $row['payability_metadata'] = []; + } + + $result[] = $row; + } + } + + /** + * Load contributions for the contact with filters applied. + * + * @return array + * + * @throws \CRM_Core_Exception + */ + private function loadContributions(): array { + $query = Contribution::get($this->checkPermissions) + ->addSelect( + 'id', + 'contact_id', + 'total_amount', + 'currency', + 'receive_date', + 'contribution_status_id:name', + 'contribution_recur_id', + 'payment_processor_id', + 'payment_processor_id.payment_processor_type_id:name' + ) + ->addWhere('contact_id', '=', $this->contactId); + + // Apply status filter + if (!empty($this->contributionStatus)) { + $query->addWhere('contribution_status_id:name', 'IN', $this->contributionStatus); + } + + // Apply date filters + if (!empty($this->startDate)) { + $query->addWhere('receive_date', '>=', $this->startDate); + } + if (!empty($this->endDate)) { + $query->addWhere('receive_date', '<=', $this->endDate . ' 23:59:59'); + } + + $contributions = $query->execute()->getArrayCopy(); + + // Normalize processor type field name + foreach ($contributions as &$contribution) { + $contribution['payment_processor_type'] = + $contribution['payment_processor_id.payment_processor_type_id:name'] ?? NULL; + unset($contribution['payment_processor_id.payment_processor_type_id:name']); + } + + return $contributions; + } + + /** + * Group contribution IDs by payment processor type. + * + * @param array $contributions + * + * @return array> + * Array keyed by processor type, containing arrays of contribution IDs. + */ + private function groupByProcessorType(array $contributions): array { + $grouped = []; + + foreach ($contributions as $contribution) { + $processorType = $contribution['payment_processor_type'] ?? '_none_'; + $grouped[$processorType][] = (int) $contribution['id']; + } + + return $grouped; + } + + /** + * Get the payability provider registry. + * + * @return \Civi\Paymentprocessingcore\Service\PayabilityProviderRegistry + */ + private function getPayabilityRegistry(): PayabilityProviderRegistry { + return \Civi::service('paymentprocessingcore.payability_registry'); + } + +} diff --git a/Civi/Api4/ContributionPayability.php b/Civi/Api4/ContributionPayability.php new file mode 100644 index 0000000..7a08f39 --- /dev/null +++ b/Civi/Api4/ContributionPayability.php @@ -0,0 +1,128 @@ +setCheckPermissions($checkPermissions); + } + + /** + * Get field definitions for the entity. + * + * @param bool $checkPermissions + * + * @return \Civi\Api4\Generic\BasicGetFieldsAction + */ + public static function getFields($checkPermissions = TRUE) { + return (new Generic\BasicGetFieldsAction(__CLASS__, __FUNCTION__, function () { + return [ + [ + 'name' => 'id', + 'title' => 'Contribution ID', + 'data_type' => 'Integer', + 'readonly' => TRUE, + ], + [ + 'name' => 'contact_id', + 'title' => 'Contact ID', + 'data_type' => 'Integer', + 'readonly' => TRUE, + ], + [ + 'name' => 'total_amount', + 'title' => 'Total Amount', + 'data_type' => 'Money', + 'readonly' => TRUE, + ], + [ + 'name' => 'currency', + 'title' => 'Currency', + 'data_type' => 'String', + 'readonly' => TRUE, + ], + [ + 'name' => 'receive_date', + 'title' => 'Receive Date', + 'data_type' => 'Timestamp', + 'readonly' => TRUE, + ], + [ + 'name' => 'contribution_status', + 'title' => 'Contribution Status', + 'data_type' => 'String', + 'readonly' => TRUE, + ], + [ + 'name' => 'payment_processor_type', + 'title' => 'Payment Processor Type', + 'data_type' => 'String', + 'readonly' => TRUE, + ], + [ + 'name' => 'can_pay_now', + 'title' => 'Can Pay Now', + 'data_type' => 'Boolean', + 'readonly' => TRUE, + 'description' => 'TRUE if user can pay, FALSE if managed by processor, NULL if no provider registered', + ], + [ + 'name' => 'payability_reason', + 'title' => 'Payability Reason', + 'data_type' => 'String', + 'readonly' => TRUE, + ], + [ + 'name' => 'payment_type', + 'title' => 'Payment Type', + 'data_type' => 'String', + 'readonly' => TRUE, + 'description' => 'Type: one_off, subscription, or payment_plan', + ], + [ + 'name' => 'payability_metadata', + 'title' => 'Payability Metadata', + 'data_type' => 'Array', + 'readonly' => TRUE, + 'description' => 'Processor-specific metadata', + ], + ]; + }))->setCheckPermissions($checkPermissions); + } + + /** + * Define permissions for the entity. + * + * @return array + */ + public static function permissions() { + return [ + 'meta' => ['access CiviCRM'], + 'default' => ['access CiviContribute'], + 'getStatus' => ['access CiviContribute'], + ]; + } + +} diff --git a/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php b/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php index 5afe070..23ba584 100644 --- a/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php +++ b/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php @@ -51,6 +51,13 @@ public function register(): void { new Definition(\Civi\Paymentprocessingcore\Service\WebhookHandlerRegistry::class) )->setShared(TRUE)->setPublic(TRUE); + // Register PayabilityProviderRegistry + // MUST be shared (singleton) so provider registrations persist across service lookups + $this->container->setDefinition( + 'paymentprocessingcore.payability_registry', + new Definition(\Civi\Paymentprocessingcore\Service\PayabilityProviderRegistry::class) + )->setShared(TRUE)->setPublic(TRUE); + // Register WebhookQueueService $this->container->setDefinition( 'paymentprocessingcore.webhook_queue', @@ -82,6 +89,10 @@ public function register(): void { 'Civi\Paymentprocessingcore\Service\WebhookHandlerRegistry', 'paymentprocessingcore.webhook_handler_registry' ); + $this->container->setAlias( + 'Civi\Paymentprocessingcore\Service\PayabilityProviderRegistry', + 'paymentprocessingcore.payability_registry' + ); $this->container->setAlias( 'Civi\Paymentprocessingcore\Service\WebhookQueueService', 'paymentprocessingcore.webhook_queue' diff --git a/Civi/Paymentprocessingcore/Payability/PayabilityProviderAdapter.php b/Civi/Paymentprocessingcore/Payability/PayabilityProviderAdapter.php new file mode 100644 index 0000000..5c3d827 --- /dev/null +++ b/Civi/Paymentprocessingcore/Payability/PayabilityProviderAdapter.php @@ -0,0 +1,49 @@ +provider = $provider; + } + + /** + * Get payability info by delegating to wrapped provider. + * + * @param array $contributionIds + * Array of contribution IDs to check. + * + * @return array + * Array keyed by contribution ID. + */ + public function getPayabilityForContributions(array $contributionIds): array { + /** @var callable $callback */ + $callback = [$this->provider, 'getPayabilityForContributions']; + return $callback($contributionIds); + } + +} diff --git a/Civi/Paymentprocessingcore/Payability/PayabilityProviderInterface.php b/Civi/Paymentprocessingcore/Payability/PayabilityProviderInterface.php new file mode 100644 index 0000000..834cd51 --- /dev/null +++ b/Civi/Paymentprocessingcore/Payability/PayabilityProviderInterface.php @@ -0,0 +1,77 @@ + $contributionIds + * Array of contribution IDs to check. + * + * @return array + * Array keyed by contribution ID, containing PayabilityResult objects. + * Each result indicates whether the contribution can be paid now, + * the reason, payment type, and any processor-specific metadata. + */ + public function getPayabilityForContributions(array $contributionIds): array; + +} diff --git a/Civi/Paymentprocessingcore/Payability/PayabilityResult.php b/Civi/Paymentprocessingcore/Payability/PayabilityResult.php new file mode 100644 index 0000000..b1fbd67 --- /dev/null +++ b/Civi/Paymentprocessingcore/Payability/PayabilityResult.php @@ -0,0 +1,142 @@ + + */ + public array $metadata; + + /** + * Construct a PayabilityResult. + * + * @param bool $canPayNow + * Whether the contribution can be paid now. + * @param string $reason + * Human-readable explanation of the status. + * @param string|null $paymentType + * Type of payment (one_off, subscription, payment_plan). + * @param array $metadata + * Processor-specific metadata. + */ + public function __construct( + bool $canPayNow, + string $reason, + ?string $paymentType = NULL, + array $metadata = [] + ) { + $this->canPayNow = $canPayNow; + $this->reason = $reason; + $this->paymentType = $paymentType; + $this->metadata = $metadata; + } + + /** + * Create a result indicating the contribution can be paid now. + * + * @param string $reason + * Explanation of why it can be paid. + * @param string|null $paymentType + * Type of payment. + * @param array $metadata + * Additional metadata. + * + * @return self + */ + public static function canPay( + string $reason = 'User can initiate payment', + ?string $paymentType = 'one_off', + array $metadata = [] + ): self { + return new self(TRUE, $reason, $paymentType, $metadata); + } + + /** + * Create a result indicating the contribution cannot be paid now. + * + * @param string $reason + * Explanation of why it cannot be paid. + * @param string|null $paymentType + * Type of payment. + * @param array $metadata + * Additional metadata. + * + * @return self + */ + public static function cannotPay( + string $reason, + ?string $paymentType = NULL, + array $metadata = [] + ): self { + return new self(FALSE, $reason, $paymentType, $metadata); + } + + /** + * Convert to array for API responses. + * + * @return array + */ + public function toArray(): array { + return [ + 'can_pay_now' => $this->canPayNow, + 'payability_reason' => $this->reason, + 'payment_type' => $this->paymentType, + 'payability_metadata' => $this->metadata, + ]; + } + +} diff --git a/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistry.php b/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistry.php new file mode 100644 index 0000000..c3fc93d --- /dev/null +++ b/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistry.php @@ -0,0 +1,142 @@ + serviceId]. + * + * @var array + */ + private array $providers = []; + + /** + * Register a provider for a specific processor type. + * + * This method is called during container compilation via addMethodCall() + * from each payment processor extension's ServiceContainer. + * + * @param string $processorType + * The processor type (e.g., 'GoCardless', 'Stripe'). + * @param string $serviceId + * The DI container service ID for the provider. + */ + public function registerProvider(string $processorType, string $serviceId): void { + $this->providers[$processorType] = $serviceId; + } + + /** + * Check if a provider is registered for a processor type. + * + * @param string $processorType + * The processor type. + * + * @return bool + * TRUE if a provider is registered, FALSE otherwise. + */ + public function hasProvider(string $processorType): bool { + return isset($this->providers[$processorType]); + } + + /** + * Get the provider for a processor type. + * + * Retrieves the provider service from the container using the registered + * service ID. Validation follows the Liskov Substitution Principle: + * + * 1. Prefers instanceof PayabilityProviderInterface (proper OOP contract) + * 2. Falls back to duck typing (method_exists) for providers that cannot + * implement the interface due to extension loading order constraints + * + * @param string $processorType + * The processor type. + * + * @return PayabilityProviderInterface + * Provider instance. + * + * @throws \RuntimeException + * If no provider is registered or provider is invalid. + */ + public function getProvider(string $processorType): PayabilityProviderInterface { + if (!isset($this->providers[$processorType])) { + throw new \RuntimeException( + sprintf( + "No payability provider registered for processor type '%s'", + $processorType + ) + ); + } + + $serviceId = $this->providers[$processorType]; + + // Check if provider is mocked in Civi::$statics (for unit testing) + if (isset(\Civi::$statics[$serviceId])) { + $provider = \Civi::$statics[$serviceId]; + } + else { + $provider = \Civi::service($serviceId); + } + + // Runtime validation: check interface implementation (Liskov Substitution) + // This check happens at runtime when all extensions are loaded, + // avoiding autoload issues that occur at class definition time. + if ($provider instanceof PayabilityProviderInterface) { + return $provider; + } + + // Fallback: Duck typing for providers that cannot implement interface + // due to extension loading order constraints. Still validates contract. + if (is_object($provider) && method_exists($provider, 'getPayabilityForContributions')) { + // Wrap in adapter to satisfy return type (Adapter Pattern) + return new PayabilityProviderAdapter($provider); + } + + throw new \RuntimeException( + sprintf( + "Provider service '%s' must implement PayabilityProviderInterface or have a getPayabilityForContributions() method", + $serviceId + ) + ); + } + + /** + * Get all registered processor types. + * + * Used by the ContributionPayability API to determine which processors + * have registered providers. Contributions for unregistered processors + * will have payability set to NULL. + * + * @return array + * List of processor types (e.g., ['GoCardless', 'Stripe']). + */ + public function getRegisteredProcessorTypes(): array { + return array_keys($this->providers); + } + + /** + * Get all registered providers (for debugging/admin purposes). + * + * @return array + * Full provider mapping [processorType => serviceId]. + */ + public function getRegisteredProviders(): array { + return $this->providers; + } + +} diff --git a/tests/phpunit/Civi/Paymentprocessingcore/Payability/PayabilityResultTest.php b/tests/phpunit/Civi/Paymentprocessingcore/Payability/PayabilityResultTest.php new file mode 100644 index 0000000..72caa79 --- /dev/null +++ b/tests/phpunit/Civi/Paymentprocessingcore/Payability/PayabilityResultTest.php @@ -0,0 +1,141 @@ + 'value'] + ); + + $this->assertTrue($result->canPayNow); + $this->assertEquals('Test reason', $result->reason); + $this->assertEquals('one_off', $result->paymentType); + $this->assertEquals(['key' => 'value'], $result->metadata); + } + + /** + * Test constructor defaults. + */ + public function testConstructorDefaults() { + $result = new PayabilityResult(FALSE, 'Reason only'); + + $this->assertFalse($result->canPayNow); + $this->assertEquals('Reason only', $result->reason); + $this->assertNull($result->paymentType); + $this->assertEquals([], $result->metadata); + } + + /** + * Test canPay() factory method with defaults. + */ + public function testCanPayFactoryMethodWithDefaults() { + $result = PayabilityResult::canPay(); + + $this->assertTrue($result->canPayNow); + $this->assertEquals('User can initiate payment', $result->reason); + $this->assertEquals('one_off', $result->paymentType); + $this->assertEquals([], $result->metadata); + } + + /** + * Test canPay() factory method with custom values. + */ + public function testCanPayFactoryMethodWithCustomValues() { + $result = PayabilityResult::canPay( + 'Custom reason', + 'custom_type', + ['mandate_id' => 'MD123'] + ); + + $this->assertTrue($result->canPayNow); + $this->assertEquals('Custom reason', $result->reason); + $this->assertEquals('custom_type', $result->paymentType); + $this->assertEquals(['mandate_id' => 'MD123'], $result->metadata); + } + + /** + * Test cannotPay() factory method. + */ + public function testCannotPayFactoryMethod() { + $result = PayabilityResult::cannotPay( + 'Managed by subscription', + 'subscription', + ['subscription_id' => 'SU123'] + ); + + $this->assertFalse($result->canPayNow); + $this->assertEquals('Managed by subscription', $result->reason); + $this->assertEquals('subscription', $result->paymentType); + $this->assertEquals(['subscription_id' => 'SU123'], $result->metadata); + } + + /** + * Test cannotPay() factory method with minimal args. + */ + public function testCannotPayFactoryMethodWithMinimalArgs() { + $result = PayabilityResult::cannotPay('Reason only'); + + $this->assertFalse($result->canPayNow); + $this->assertEquals('Reason only', $result->reason); + $this->assertNull($result->paymentType); + $this->assertEquals([], $result->metadata); + } + + /** + * Test toArray() returns correct structure. + */ + public function testToArrayReturnsCorrectStructure() { + $result = new PayabilityResult( + TRUE, + 'User can pay', + 'one_off', + ['processor_id' => 1] + ); + + $array = $result->toArray(); + + $this->assertEquals([ + 'can_pay_now' => TRUE, + 'payability_reason' => 'User can pay', + 'payment_type' => 'one_off', + 'payability_metadata' => ['processor_id' => 1], + ], $array); + } + + /** + * Test toArray() handles null payment type. + */ + public function testToArrayHandlesNullPaymentType() { + $result = PayabilityResult::cannotPay('Unknown payment type'); + + $array = $result->toArray(); + + $this->assertNull($array['payment_type']); + $this->assertFalse($array['can_pay_now']); + } + + /** + * Test toArray() handles empty metadata. + */ + public function testToArrayHandlesEmptyMetadata() { + $result = PayabilityResult::canPay(); + + $array = $result->toArray(); + + $this->assertEquals([], $array['payability_metadata']); + } + +} diff --git a/tests/phpunit/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistryTest.php b/tests/phpunit/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistryTest.php new file mode 100644 index 0000000..f32f199 --- /dev/null +++ b/tests/phpunit/Civi/Paymentprocessingcore/Service/PayabilityProviderRegistryTest.php @@ -0,0 +1,203 @@ +registry = new PayabilityProviderRegistry(); + } + + /** + * Test registerProvider() adds provider to registry. + */ + public function testRegisterProviderAddsProviderToRegistry() { + $this->registry->registerProvider('GoCardless', 'gocardless.payability_provider'); + + $this->assertTrue($this->registry->hasProvider('GoCardless')); + } + + /** + * Test registerProvider() allows multiple processors. + */ + public function testRegisterProviderAllowsMultipleProcessors() { + $this->registry->registerProvider('GoCardless', 'gocardless.payability_provider'); + $this->registry->registerProvider('Stripe', 'stripe.payability_provider'); + + $this->assertTrue($this->registry->hasProvider('GoCardless')); + $this->assertTrue($this->registry->hasProvider('Stripe')); + } + + /** + * Test hasProvider() returns false for unregistered provider. + */ + public function testHasProviderReturnsFalseForUnregisteredProvider() { + $this->assertFalse($this->registry->hasProvider('UnknownProcessor')); + } + + /** + * Test getProvider() throws exception for unregistered provider. + */ + public function testGetProviderThrowsExceptionForUnregisteredProvider() { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("No payability provider registered for processor type 'GoCardless'"); + + $this->registry->getProvider('GoCardless'); + } + + /** + * Test getProvider() returns provider implementing interface directly. + * + * Providers that implement PayabilityProviderInterface are returned as-is + * (preferred approach, Liskov Substitution Principle). + */ + public function testGetProviderReturnsInterfaceImplementorDirectly(): void { + // Create provider that implements the interface + $mockProvider = new class implements PayabilityProviderInterface { + + /** + * Get payability for contributions. + * + * @phpstan-param array $contributionIds + * @phpstan-return array + */ + public function getPayabilityForContributions(array $contributionIds): array { + return [ + 1 => PayabilityResult::canPay('Test reason', 'one_off'), + ]; + } + + }; + + \Civi::$statics['test.interface_provider'] = $mockProvider; + $this->registry->registerProvider('TestProcessor', 'test.interface_provider'); + + $provider = $this->registry->getProvider('TestProcessor'); + + // Should return the exact same instance (no adapter needed) + $this->assertInstanceOf(PayabilityProviderInterface::class, $provider); + $this->assertSame($mockProvider, $provider); + + unset(\Civi::$statics['test.interface_provider']); + } + + /** + * Test getProvider() wraps duck-typed provider in adapter. + * + * Providers with getPayabilityForContributions() method but no interface + * implementation are wrapped in an adapter (Adapter Pattern) to satisfy + * the return type. This supports providers that cannot use `implements` + * due to autoload. + */ + public function testGetProviderWrapsDuckTypedProviderInAdapter(): void { + // Create provider with method but no interface (duck typing) + $mockProvider = new class { + + /** + * Get payability for contributions. + * + * @phpstan-param array $contributionIds + * @phpstan-return array + */ + public function getPayabilityForContributions(array $contributionIds): array { + return [ + 1 => PayabilityResult::cannotPay('Duck typed reason', 'subscription'), + ]; + } + + }; + + \Civi::$statics['test.duck_provider'] = $mockProvider; + $this->registry->registerProvider('TestProcessor', 'test.duck_provider'); + + $provider = $this->registry->getProvider('TestProcessor'); + + // Should be wrapped in adapter implementing interface + $this->assertInstanceOf(PayabilityProviderInterface::class, $provider); + // Adapter should delegate to original provider + $result = $provider->getPayabilityForContributions([1]); + $this->assertArrayHasKey(1, $result); + $this->assertFalse($result[1]->canPayNow); + + unset(\Civi::$statics['test.duck_provider']); + } + + /** + * Test getProvider() throws exception if service has no required method. + */ + public function testGetProviderThrowsExceptionIfServiceHasNoRequiredMethod(): void { + \Civi::$statics['test.invalid'] = new \stdClass(); + $this->registry->registerProvider('TestProcessor', 'test.invalid'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("must implement PayabilityProviderInterface or have a getPayabilityForContributions() method"); + + try { + $this->registry->getProvider('TestProcessor'); + } + finally { + unset(\Civi::$statics['test.invalid']); + } + } + + /** + * Test getRegisteredProcessorTypes() returns all registered processors. + */ + public function testGetRegisteredProcessorTypesReturnsAllProcessors() { + $this->registry->registerProvider('GoCardless', 'gocardless.provider'); + $this->registry->registerProvider('Stripe', 'stripe.provider'); + $this->registry->registerProvider('Deluxe', 'deluxe.provider'); + + $processors = $this->registry->getRegisteredProcessorTypes(); + + $this->assertCount(3, $processors); + $this->assertContains('GoCardless', $processors); + $this->assertContains('Stripe', $processors); + $this->assertContains('Deluxe', $processors); + } + + /** + * Test getRegisteredProcessorTypes() returns empty array when no providers. + */ + public function testGetRegisteredProcessorTypesReturnsEmptyArrayWhenNoProviders() { + $processors = $this->registry->getRegisteredProcessorTypes(); + + $this->assertIsArray($processors); + $this->assertEmpty($processors); + } + + /** + * Test getRegisteredProviders() returns full mapping. + */ + public function testGetRegisteredProvidersReturnsFullMapping() { + $this->registry->registerProvider('GoCardless', 'gocardless.provider'); + $this->registry->registerProvider('Stripe', 'stripe.provider'); + + $providers = $this->registry->getRegisteredProviders(); + + $this->assertEquals([ + 'GoCardless' => 'gocardless.provider', + 'Stripe' => 'stripe.provider', + ], $providers); + } + +}