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); + } + +}