diff --git a/.env.dist b/.env.dist new file mode 100644 index 00000000..e703bd8c --- /dev/null +++ b/.env.dist @@ -0,0 +1,10 @@ +# URL of the paypal API v1 endpoint +PAYPAL_API_URL="https://example.com/" + +# See https://developer.paypal.com/api/rest/authentication/ +PAYPAL_API_CLIENT_ID="something" +PAYPAL_API_CLIENT_SECRET="n0ts0s3cr1t" + +# Database connection string for connecting to a local database wit the the doctrine CLI +# Only needed if you're using the bin/doctrine script in the bounded context +DB_DSN="mysqli:/user:password@dtabase-server/database-name" diff --git a/.gitignore b/.gitignore index 9ff28287..354cdf7d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ coverage/ .idea/ +.env + diff --git a/README.md b/README.md index 0b6198f7..9d5821b1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Code Coverage](https://scrutinizer-ci.com/g/wmde/fundraising-payments/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/wmde/fundraising-payments/?branch=master) Bounded Context for the Wikimedia Deutschland fundraising payment (sub-)domain. Used by the -[user facing donation application](https://github.com/wmde/FundraisingFrontend) and the +[user facing donation application](https://github.com/wmde/fundraising-application) and the "Fundraising Operations Center" (which is not public software). ## Installation @@ -22,6 +22,19 @@ file that just defines a dependency on Fundraising Payments 1.x: } ``` +## Setting up the PayPal API +The payment context calls the PayPal REST API to create payments. +These API calls need credentials and a one-time setup of subscription plans +(i.e. definition of recurring payments) on the PayPal server. +There is a command line tool to do the subscription plan setup. +You can call this command (`create-subscription-plans`) from the console in [Fundraising Application](https://github.com/wmde/fundraising-application) +or from the `bin/console` file in this bounded context. + +There is another command, `list-subscription-plans` that lists all the configured plans. + +See [Configuring the PayPal API](docs/paypal_api.md) for more details on these commands and their configuration. + + ## Development This project has a [Makefile](Makefile) that runs all tasks in Docker containers via diff --git a/bin/console b/bin/console new file mode 100644 index 00000000..a4f2e8e5 --- /dev/null +++ b/bin/console @@ -0,0 +1,41 @@ +#!/usr/bin/env php +load( __DIR__ . '/../.env' ); + +function createPayPalAPI(): \WMDE\Fundraising\PaymentContext\Services\PayPal\PayPalAPI { + $clientId = $_ENV['PAYPAL_API_CLIENT_ID'] ?? ''; + $secret = $_ENV['PAYPAL_API_CLIENT_SECRET'] ?? ''; + $baseUri = $_ENV['PAYPAL_API_URL'] ?? ''; + if ( !$clientId || !$secret || !$baseUri ) { + echo "You must put PAYPAL_API_URL, PAYPAL_API_CLIENT_ID and PAYPAL_API_CLIENT_SECRET\n"; + exit( Command::FAILURE ); + } + + return new GuzzlePaypalAPI( + new Client( [ 'base_uri' => $baseUri ] ), + $clientId, + $secret, + new NullLogger() + ); +} + +$api = createPayPalAPI(); + +$application = new Application(); + +$application->add( new \WMDE\Fundraising\PaymentContext\Commands\ListSubscriptionPlansCommand( $api ) ); +$application->add( new \WMDE\Fundraising\PaymentContext\Commands\CreateSubscriptionPlansCommand( $api ) ); + + +$application->run(); diff --git a/bin/doctrine b/bin/doctrine new file mode 100755 index 00000000..15e01bf0 --- /dev/null +++ b/bin/doctrine @@ -0,0 +1,42 @@ +#!/usr/bin/env php +load( __DIR__ . '/../.env' ); + +function createEntityManager(): EntityManager { + if (empty( $_ENV['DB_DSN'] ) ) { + echo "You must set the database connection string in 'DB_DSN'\n"; + exit(1); + } + $dsnParser = new DsnParser(['mysql' => 'pdo_mysql']); + $connectionParams = $dsnParser + ->parse( $_ENV['DB_DSN'] ); + $connection = DriverManager::getConnection( $connectionParams ); + + $contextFactory = new PaymentContextFactory(); + $contextFactory->registerCustomTypes( $connection ); + $doctrineConfig = ORMSetup::createXMLMetadataConfiguration( + $contextFactory->getDoctrineMappingPaths(), + true + ); + + return new EntityManager( $connection, $doctrineConfig ); +} + + +ConsoleRunner::run( + new SingleManagerProvider(createEntityManager()), + [] +); diff --git a/composer.json b/composer.json index 98507cfc..366149f1 100644 --- a/composer.json +++ b/composer.json @@ -12,9 +12,13 @@ "doctrine/orm": "^2.16.1", "doctrine/dbal": "^3.3", "doctrine/migrations": "^3.5", + "guzzlehttp/guzzle": "^7.4", "sofort/sofortlib-php": "^3.2", - "symfony/cache": "^6.0", - "guzzlehttp/guzzle": "^7.4" + "symfony/cache": "^6.3", + "symfony/console": "^6.3", + "symfony/dotenv": "^6.3", + "symfony/yaml": "^6.3", + "symfony/config": "^6.3" }, "require-dev": { "phpunit/phpunit": "~10.1", @@ -22,7 +26,8 @@ "wmde/inspector-generator": "dev-main", "phpstan/phpstan": "~1.3", "phpstan/phpstan-phpunit": "^1.0", - "qossmic/deptrac-shim": "^1.0" + "qossmic/deptrac-shim": "^1.0", + "wmde/psr-log-test-doubles": "~v3.2.0" }, "repositories": [ { diff --git a/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalOrder.dcm.xml b/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalOrder.dcm.xml new file mode 100644 index 00000000..d05611db --- /dev/null +++ b/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalOrder.dcm.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalPaymentIdentifier.dcm.xml b/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalPaymentIdentifier.dcm.xml new file mode 100644 index 00000000..e33c48dc --- /dev/null +++ b/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalPaymentIdentifier.dcm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalSubscription.dcm.xml b/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalSubscription.dcm.xml new file mode 100644 index 00000000..76c1ddd2 --- /dev/null +++ b/config/DoctrineClassMapping/WMDE.Fundraising.PaymentContext.Domain.Model.PayPalSubscription.dcm.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/config/paypal_api.example.yml b/config/paypal_api.example.yml new file mode 100644 index 00000000..044cf251 --- /dev/null +++ b/config/paypal_api.example.yml @@ -0,0 +1,102 @@ +# Configuration file for setting up subscription plans for different products and locales +# +# The first level of the configuration is the "product" level. +# In our application, we currently have two products "donation" and "membership". +# The distinction lies in the _names_ of the products and subscription plans, +# which will be shown on the PayPal page. +# +# The second level of the configuration is the locale level. +# Each product has to have definitions for each locale, e.g. de_DE or en_GB. + +donation: + de_DE: + # `product_id` is an ID generated by us. It follows the pattern of __ + # is the first part of the locale, e.g. 'de' in de_DE + # The CLI for creating subscription plans uses this product ID to + # reference the product for each subscription plan + product_id: 'donation_de_1' + # The localized product name, used both for subscription plans and one-time payments + # Currently not shown in the PayPal UI for subscription plans, only on the + # PayPal checkout page for one-time payments + product_name: 'Spende an Wikimedia' + subscription_plans: + # ID is generated automatically by PayPal when we create subscription plans with the CLI + # Before the creation, ID can be a stub, after the creation please insert the generated IDs + - id: '123' + # the (public) string that the user sees on the checkout page at PayPal + name: 'Monatliche Spende an Wikimedia' + # internal value, should be a valid string for type PaymentInterval + interval: 'Monthly' + - id: '496' + name: 'Vierteljährliche Spende an Wikimedia' + interval: 'Quarterly' + - id: '456' + name: 'Halbjährliche Spende an Wikimedia' + interval: 'HalfYearly' + - id: 'ABC' + name: 'Jährliche Spende an Wikimedia' + interval: 'Yearly' + # the page the user gets redirected to when the PayPal payment was successfully created + # Where do they get redirected from? From PayPal page + # The UrlAuthenticator will append access tokens to the URL + return_url: 'https://test-spenden.wikimedia.de/show-donation-confirmation?' + # the page the user gets redirected to when they hit 'cancel' + # Where do they get redirected from? From PayPal page + # The UrlAuthenticator will append access tokens to the URL + cancel_url: 'https://test-spenden.wikimedia.de/new?id=' + + en_GB: + product_id: 'donation_en_1' + product_name: 'Donation to Wikimedia' + subscription_plans: + - id: '786' + name: 'Monthly donation to Wikimedia' + interval: 'Monthly' + - id: 'GHI' + name: 'Quarterly donation to Wikimedia' + interval: 'Quarterly' + - id: 'JKL' + name: 'Half yearly donation to Wikimedia' + interval: 'HalfYearly' + - id: 'NMP' + name: 'Yearly donation to Wikimedia' + interval: 'Yearly' + return_url: 'https://test-spenden.wikimedia.de/show-donation-confirmation?&lang=en' + cancel_url: 'https://test-spenden.wikimedia.de/new?&lang=en' +membership: + de_DE: + product_id: 'membership_de_1' + product_name: 'Mitgliedschaftsbeitrag an Wikimedia' + subscription_plans: + - id: '333' + name: 'Monatlicher Mitgliedschaftsbeitrag an Wikimedia' + interval: 'Monthly' + - id: 'MNT' + name: 'Vierteljährlicher Mitgliedschaftsbeitrag an Wikimedia' + interval: 'Quarterly' + - id: 'DEN' + name: 'Halbjährlicher Mitgliedschaftsbeitrag an Wikimedia' + interval: 'HalfYearly' + - id: 'MBB' + name: 'Jährlicher Mitgliedschaftsbeitrag an Wikimedia' + interval: 'Yearly' + return_url: 'https://test-spenden.wikimedia.de/show-membership-confirmation?' + cancel_url: 'https://test-spenden.wikimedia.de/apply-for-membership?' + en_GB: + product_id: 'membership_en_1' + product_name: 'Wikimedia Membership' + subscription_plans: + - id: '716' + name: 'Monthly membership fee for Wikimedia' + interval: 'Monthly' + - id: 'XZA' + name: 'Quarterly membership fee for Wikimedia' + interval: 'Quarterly' + - id: 'VCX' + name: 'Half yearly membership fee for Wikimedia' + interval: 'HalfYearly' + - id: 'MNB' + name: 'Yearly membership fee for Wikimedia' + interval: 'Yearly' + return_url: 'https://test-spenden.wikimedia.de/show-membership-confirmation?lang=en' + cancel_url: 'https://test-spenden.wikimedia.de/apply-for-membership?lang=en' diff --git a/deptrac.yaml b/deptrac.yaml index 7f7424d1..232626fa 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -16,14 +16,28 @@ deptrac: collectors: - type: classLike value: WMDE\\Fundraising\\PaymentContext\\DataAccess.* - - name: ServiceInterface + - type: class + value: WMDE\\Fundraising\\PaymentContext\\ScalarTypeConverter + # Interfaces and value objects for services + - name: ServiceModel collectors: - type: interface value: WMDE\\Fundraising\\PaymentContext\\Services.* + - type: class + value: WMDE\\Fundraising\\PaymentContext\\Services\\Paypal\\Model.* + - type: classLike + value: WMDE\\Fundraising\\PaymentContext\\Services\\PaymentUrlGenerator\\Sofort.* - name: Service collectors: - - type: class - value: WMDE\\Fundraising\\PaymentContext\\Services.* + - type: bool + must: + - type: class + value: WMDE\\Fundraising\\PaymentContext\\Services.* + must_not: + - type: class + value: WMDE\\Fundraising\\PaymentContext\\Services\\Paypal\\Model.* + - type: class + value: WMDE\\Fundraising\\PaymentContext\\Services\\PaymentUrlGenerator\\Sofort.* # Domain libraries from WMDE - name: DomainLibrary collectors: @@ -44,6 +58,14 @@ deptrac: collectors: - type: classNameRegex value: /^Sofort\\.*/ + - name: Psr + collectors: + - type: classNameRegex + value: /^Psr\\.*/ + - name: Symfony Config + collectors: + - type: classNameRegex + value: /^Symfony\\Component\\(Config|Yaml)\\.*/ ruleset: Domain: - DomainLibrary @@ -53,6 +75,7 @@ deptrac: - ServiceInterface - DomainLibrary - DomainValidators + - ServiceModel DataAccess: - Domain - DomainLibrary @@ -67,24 +90,29 @@ deptrac: # into the Domain layer. - UseCase - DataAccess - - ServiceInterface + - ServiceModel # Maybe the Services should not directly depend on Doctrine but should go through the `DataAccess` layer # TODO: Move DoctrineTransactionIdFinder.php and UniquePaymentReferenceCodeGenerator.php into DataAccess - Doctrine - Guzzle - Sofort - ServiceInterface: + - Psr + - Symfony Config + ServiceModel: - Domain - DomainLibrary + - ServiceInterface formatters: graphviz: groups: Service: - Service - - ServiceInterface + - ServiceModel Vendor: - Doctrine - Guzzle - Sofort + - Psr + - Symfony Config diff --git a/docs/paypal_api.md b/docs/paypal_api.md new file mode 100644 index 00000000..908998c5 --- /dev/null +++ b/docs/paypal_api.md @@ -0,0 +1,78 @@ +# PayPal API + +## How we use the PayPal API +The `PayPalAPIUrlGenerator` makes a request to the PayPal REST API to "announce" a payment to PayPal. +PayPal answers with a URL to send the user to, where the user can then log in to PayPal and confirm the payment. + +In order for the `PayPalAPIUrlGenerator` to send a request to do a recurring payment, +we need to pass the ID of a so-called "subscription plan". A subscription plan consists of + +- a name (shown in the PayPal web UI) +- an ID (generated by PayPal) +- a product ID (provided by us, see "Configuration" below) +- a payment interval (monthly, quarterly, half-yearly, yearly) + +The subscription plans need to be set up once. In order to set them up, you need a configuration file (see "Configuration" below) and then use the command `create-subscription-plans` to do special API calls that create the plans (see below). + +One-time-payments use a different API endpoint and no subscription plans. +We abstracted this behavior in `PayPalAPIUrlGenerator`, which will examine each payment and call the appropriate API endpoint. + +## Providing API credentials +The commands that communicate with the PayPal API need credentials. Regardless if you use the standalone `bin/console` in this bounded context or the `bin/console` in the [Fundraising Application](https://github.com/wmde/fundraising-application), the format of the credentials file is the same: + +``` +# URL of the paypal API v1 endpoint +PAYPAL_API_URL="https://api-m.sandbox.paypal.com/" + +# See https://developer.paypal.com/api/rest/authentication/ +PAYPAL_API_CLIENT_ID="something" +PAYPAL_API_CLIENT_SECRET="n0ts0s3cr1t" +``` + +Put these key-value-pairs in a file called `.env`, either in the directory of this bounded context or in the directory of the Fundraising application. + +The `.env` files should be part of the deployment, as different environments will have different credentials and URLs (sandbox vs. no sandbox). For development and testing purposes you can create an `.env` file in this bounded context. + +🚨 Do not put the `.env` file in the Git repository! 🚨 + +## Preparing the configuration file + +The configuration file for setting up the subscription plans in the production environment is located at: (TODO FILE PATH, add when https://phabricator.wikimedia.org/T340734 is done) +For testing, initial setup and running the commands without the fundraising application, please have a look at [the example file](../config/paypal_api.example.yml) + +The configuration file has two purposes: + +- Provide names and intervals for the command that generates the subscription plans (with IDs) on the PayPal server +- Provide IDs of the subscription plans and product names for one-time-payments for the application + +### Steps to create a complete configuration file for production + + 1. Write the basic structure with products (id and name) and subscription plans (name and interval, id can be a stub). Be careful with the name and structure, currently there is no code to edit them and PayPal does not allow to delete anything! + 2. Run the command to create the subscription plans (see below). + 3. Replace the stub the IDs of the subscription plans with the IDs generated by PayPal. In order to get these IDs you can read the console output while running the creation command or use the "list" command that lists existing subscription plans (See below) + +Side note: +For the payment code itself, the product and language keys are arbitrary. The combination of product and language "points" to a specific section of the configuration (see `PayPalAPIURLGeneratorConfigFactory`). Passing the right combination of product and language key is the concern of the code *using* the Payment bounded context. + + +## Command line interface + +The commands are part of a "console" binary. In this bounded context it's `bin/console`. + +If you want to run the commands inside a container, prefix them with the necessary `docker` or `docker-compose` commands, e.g. + +```shell +docker-compose run app php bin/console app:list-subscription-plans +``` + +### Creating subscription plans + +```shell +bin/console app:create-subscription-plans +``` + +### Listing the subscription plans + +```shell +bin/console app:list-subscription-plans +``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..7360513c --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,25 @@ +parameters: + ignoreErrors: + # A workaround for Doctrine not specifying an UniqueConstraintViolationException being thrown + # See https://github.com/doctrine/orm/issues/7780 + # This rule might be unnecessary in the future with a fixe discussed in + # https://github.com/phpstan/phpstan-doctrine/issues/295 + - message: /Doctrine\\DBAL\\Exception\\UniqueConstraintViolationException is never thrown in the try block/ + path: src/DataAccess/DoctrinePaymentRepository.php + count: 1 + + # Workaround for loading the Paypal configuration YAML file (mixed content) and assuming a certain array shape (validated with Symfony config) + # In the future, Symfony Config might generate PHPStan types that would allow us to at least type the input to PayPalAPIURLGeneratorConfigFactory + # and the output of PayPalPaymentProviderAdapterConfigReader + - message: "#^Method WMDE\\\\Fundraising\\\\PaymentContext\\\\Services\\\\PayPal\\\\PayPalPaymentProviderAdapterConfigFactory\\:\\:createConfig\\(\\) has parameter \\$allConfigs with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Services/PayPal/PayPalPaymentProviderAdapterConfigFactory.php + - message: "#^Method WMDE\\\\Fundraising\\\\PaymentContext\\\\Services\\\\PayPal\\\\PayPalPaymentProviderAdapterConfigReader\\:\\:checkProductAndSubscriptionPlanIdsAreUnique\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Services/PayPal/PayPalPaymentProviderAdapterConfigReader.php + - message: "#^Method WMDE\\\\Fundraising\\\\PaymentContext\\\\Services\\\\PayPal\\\\PayPalPaymentProviderAdapterConfigReader\\:\\:readConfig\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Services/PayPal/PayPalPaymentProviderAdapterConfigReader.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:arrayPrototype\\(\\)\\.$#" + count: 1 + path: src/Services/PayPal/PayPalPaymentProviderAdapterConfigSchema.php diff --git a/phpstan.neon b/phpstan.neon index c0798aea..8f02c625 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,16 +1,5 @@ includes: - - vendor/phpstan/phpstan-phpunit/extension.neon - - vendor/phpstan/phpstan-phpunit/rules.neon -parameters: - ignoreErrors: - # A workaround for Doctrine not specifying an UniqueConstraintViolationException being thrown - # See https://github.com/doctrine/orm/issues/7780 - # This rule might be unneccessary in the future with a fix discussed in - # https://github.com/phpstan/phpstan-doctrine/issues/295 - - message: /Doctrine\\DBAL\\Exception\\UniqueConstraintViolationException is never thrown in the try block/ - path: src/DataAccess/DoctrinePaymentRepository.php - count: 1 + - phpstan-baseline.neon + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon - excludePaths: - analyse: - - tests/Inspectors diff --git a/src/Commands/CreateSubscriptionPlansCommand.php b/src/Commands/CreateSubscriptionPlansCommand.php new file mode 100644 index 00000000..39cf5eb9 --- /dev/null +++ b/src/Commands/CreateSubscriptionPlansCommand.php @@ -0,0 +1,100 @@ +addArgument( + 'configFile', + InputArgument::REQUIRED, + 'File name of PayPal subscription plan configuration' + ); + } + + protected function execute( InputInterface $input, OutputInterface $output ): int { + $useCase = new CreateSubscriptionPlanForProductUseCase( $this->paypalAPI ); + + $configuration = PayPalPaymentProviderAdapterConfigReader::readConfig( + ScalarTypeConverter::toString( $input->getArgument( 'configFile' ) ) + ); + + foreach ( $configuration as $productConfiguration ) { + foreach ( $productConfiguration as $languageSpecificConfiguration ) { + foreach ( $languageSpecificConfiguration['subscription_plans'] as $idx => $planConfiguration ) { + $intervalName = ScalarTypeConverter::toString( $planConfiguration['interval'] ); + try { + $parsedInterval = PaymentInterval::fromString( $intervalName ); + } catch ( \OutOfBoundsException ) { + $output->writeln( "$intervalName is not an allowed interval name" ); + return Command::INVALID; + } + $result = $useCase->create( new CreateSubscriptionPlanRequest( + $languageSpecificConfiguration['product_id'], + $languageSpecificConfiguration['product_name'], + $parsedInterval, + $planConfiguration['name'] + ) ); + + if ( $result instanceof ErrorResult ) { + $output->writeln( $result->message ); + return Command::FAILURE; + } + if ( $idx === 0 ) { + $output->writeln( $this->formattedOutputForProduct( $result ) ); + } + $output->writeln( $this->formattedOutputForSubscriptionPlan( $result ) ); + } + } + } + return Command::SUCCESS; + } + + private function formattedOutputForProduct( SuccessResult $result ): string { + return sprintf( + "Product '%s' (ID %s) %s", + $result->successfullyCreatedProduct->name, + $result->successfullyCreatedProduct->id, + $result->productAlreadyExisted ? self::ALREADY_EXISTS_SNIPPET : self::WAS_CREATED_SNIPPET + ); + } + + private function formattedOutputForSubscriptionPlan( SuccessResult $result ): string { + return sprintf( + ' The %s subscription plan "%s" (ID "%s") %s.', + strtolower( $result->successfullyCreatedSubscriptionPlan->monthlyInterval->name ), + $result->successfullyCreatedSubscriptionPlan->name, + $result->successfullyCreatedSubscriptionPlan->id, + $result->subscriptionPlanAlreadyExisted ? self::ALREADY_EXISTS_SNIPPET : self::WAS_CREATED_SNIPPET + ); + } +} diff --git a/src/Commands/ListSubscriptionPlansCommand.php b/src/Commands/ListSubscriptionPlansCommand.php new file mode 100644 index 00000000..a1e6cb02 --- /dev/null +++ b/src/Commands/ListSubscriptionPlansCommand.php @@ -0,0 +1,48 @@ +paypalAPI->listProducts(); + + if ( count( $products ) === 0 ) { + $output->writeln( 'No products and plans configured' ); + return Command::SUCCESS; + } + + $table = new Table( $output ); + $table->setHeaders( [ 'Product ID', 'Subscription plan ID', 'Interval' ] ); + + foreach ( $products as $product ) { + foreach ( $this->paypalAPI->listSubscriptionPlansForProduct( $product->id ) as $subscriptionPlanForProduct ) { + $table->addRow( [ $product->id, $subscriptionPlanForProduct->id, $subscriptionPlanForProduct->monthlyInterval->name ] ); + } + $table->addRow( new TableSeparator() ); + + } + $table->render(); + return Command::SUCCESS; + } +} diff --git a/src/DataAccess/DoctrinePaymentIdRepository.php b/src/DataAccess/DoctrinePaymentIdRepository.php index c92d2345..2400a388 100644 --- a/src/DataAccess/DoctrinePaymentIdRepository.php +++ b/src/DataAccess/DoctrinePaymentIdRepository.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Result; use Doctrine\ORM\EntityManager; use WMDE\Fundraising\PaymentContext\Domain\PaymentIdRepository; +use WMDE\Fundraising\PaymentContext\ScalarTypeConverter; class DoctrinePaymentIdRepository implements PaymentIdRepository { diff --git a/src/DataAccess/DoctrinePaymentRepository.php b/src/DataAccess/DoctrinePaymentRepository.php index fa4171cf..b1cdee30 100644 --- a/src/DataAccess/DoctrinePaymentRepository.php +++ b/src/DataAccess/DoctrinePaymentRepository.php @@ -9,9 +9,11 @@ use WMDE\Fundraising\PaymentContext\Domain\Exception\PaymentNotFoundException; use WMDE\Fundraising\PaymentContext\Domain\Exception\PaymentOverrideException; use WMDE\Fundraising\PaymentContext\Domain\Model\Payment; +use WMDE\Fundraising\PaymentContext\Domain\Model\PayPalPaymentIdentifier; use WMDE\Fundraising\PaymentContext\Domain\PaymentRepository; +use WMDE\Fundraising\PaymentContext\Domain\PayPalPaymentIdentifierRepository; -class DoctrinePaymentRepository implements PaymentRepository { +class DoctrinePaymentRepository implements PaymentRepository, PayPalPaymentIdentifierRepository { public function __construct( private EntityManager $entityManager ) { } @@ -41,4 +43,9 @@ public function getPaymentById( int $id ): Payment { return $payment; } + public function storePayPalIdentifier( PayPalPaymentIdentifier $identifier ): void { + $this->entityManager->persist( $identifier ); + $this->entityManager->flush(); + } + } diff --git a/src/DataAccess/DoctrineTypes/Euro.php b/src/DataAccess/DoctrineTypes/Euro.php index 212caf90..3125ca77 100644 --- a/src/DataAccess/DoctrineTypes/Euro.php +++ b/src/DataAccess/DoctrineTypes/Euro.php @@ -7,7 +7,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; use WMDE\Euro\Euro as WMDEEuro; -use WMDE\Fundraising\PaymentContext\DataAccess\ScalarTypeConverter; +use WMDE\Fundraising\PaymentContext\ScalarTypeConverter; class Euro extends Type { diff --git a/src/DataAccess/DoctrineTypes/Iban.php b/src/DataAccess/DoctrineTypes/Iban.php index b1cc13f7..8c955b83 100644 --- a/src/DataAccess/DoctrineTypes/Iban.php +++ b/src/DataAccess/DoctrineTypes/Iban.php @@ -6,8 +6,8 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; -use WMDE\Fundraising\PaymentContext\DataAccess\ScalarTypeConverter; use WMDE\Fundraising\PaymentContext\Domain\Model\Iban as WMDEIban; +use WMDE\Fundraising\PaymentContext\ScalarTypeConverter; class Iban extends Type { diff --git a/src/DataAccess/DoctrineTypes/PaymentInterval.php b/src/DataAccess/DoctrineTypes/PaymentInterval.php index 3b3b1a8c..07a02f2e 100644 --- a/src/DataAccess/DoctrineTypes/PaymentInterval.php +++ b/src/DataAccess/DoctrineTypes/PaymentInterval.php @@ -6,8 +6,8 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; -use WMDE\Fundraising\PaymentContext\DataAccess\ScalarTypeConverter; use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentInterval as DomainPaymentInterval; +use WMDE\Fundraising\PaymentContext\ScalarTypeConverter; class PaymentInterval extends Type { diff --git a/src/DataAccess/Migrations/Version20231012164127.php b/src/DataAccess/Migrations/Version20231012164127.php new file mode 100644 index 00000000..2045f929 --- /dev/null +++ b/src/DataAccess/Migrations/Version20231012164127.php @@ -0,0 +1,37 @@ +addSql( <<<'EOT' +CREATE TABLE payment_paypal_identifier ( + payment_id INT NOT NULL, + identifier_type VARCHAR(1) NOT NULL, + subscription_id VARCHAR(255) DEFAULT NULL, + transaction_id VARCHAR(255) DEFAULT NULL, + order_id VARCHAR(255) DEFAULT NULL, + PRIMARY KEY(payment_id) +) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB +EOT + ); + $this->addSql( 'ALTER TABLE payment_paypal_identifier ADD CONSTRAINT FK_D7AFB034C3A3BB FOREIGN KEY (payment_id) REFERENCES payment (id)' ); + $this->addSql( 'CREATE INDEX payment_paypal_identifier_transaction_id_index ON payment_paypal_identifier (transaction_id)' ); + $this->addSql( 'CREATE INDEX payment_paypal_identifier_order_id_index ON payment_paypal_identifier (order_id)' ); + $this->addSql( 'CREATE INDEX payment_paypal_identifier_subscription_id_index ON payment_paypal_identifier (subscription_id)' ); + } + + public function down( Schema $schema ): void { + $this->addSql( 'ALTER TABLE payment_paypal_identifier DROP FOREIGN KEY FK_D7AFB034C3A3BB' ); + $this->addSql( 'DROP TABLE payment_paypal_identifier' ); + } +} diff --git a/src/DataAccess/ScalarTypeConverter.php b/src/DataAccess/ScalarTypeConverter.php deleted file mode 100644 index dbbff379..00000000 --- a/src/DataAccess/ScalarTypeConverter.php +++ /dev/null @@ -1,26 +0,0 @@ -getInterval()->isRecurring() ) { + throw new \DomainException( self::class . ' can only be used for one-time payments' ); + } + $trimmedOrderId = trim( $orderID ); + if ( empty( $trimmedOrderId ) ) { + throw new \DomainException( 'Order ID must not be empty' ); + } + + parent::__construct( $payment ); + $this->orderId = $trimmedOrderId; + $this->transactionId = $transactionId; + } + + public function getTransactionId(): ?string { + return $this->transactionId; + } + + public function setTransactionId( string $transactionId ): void { + if ( trim( $transactionId ) === '' ) { + throw new \DomainException( 'Transaction ID must not be empty when setting it explicitly' ); + } + if ( $this->transactionId !== null && $this->transactionId !== $transactionId ) { + throw new \DomainException( 'Transaction ID must not be changed' ); + } + $this->transactionId = $transactionId; + } + + public function getOrderId(): string { + return $this->orderId; + } + +} diff --git a/src/Domain/Model/PayPalPayment.php b/src/Domain/Model/PayPalPayment.php index e3d37a3f..85c43232 100644 --- a/src/Domain/Model/PayPalPayment.php +++ b/src/Domain/Model/PayPalPayment.php @@ -105,11 +105,7 @@ private function createFollowUpPayment( array $transactionData, PaymentIdReposit } private function isBookedInitialPayment(): bool { - return $this->isBooked() && $this->parentPayment === null && $this->isRecurringPayment(); - } - - private function isRecurringPayment(): bool { - return $this->interval !== PaymentInterval::OneTime; + return $this->isBooked() && $this->parentPayment === null && $this->interval->isRecurring(); } public function getDisplayValues(): array { diff --git a/src/Domain/Model/PayPalPaymentIdentifier.php b/src/Domain/Model/PayPalPaymentIdentifier.php new file mode 100644 index 00000000..fabc9c3b --- /dev/null +++ b/src/Domain/Model/PayPalPaymentIdentifier.php @@ -0,0 +1,22 @@ +payment = $payment; + } + + public function getPayment(): PayPalPayment { + return $this->payment; + } +} diff --git a/src/Domain/Model/PayPalSubscription.php b/src/Domain/Model/PayPalSubscription.php new file mode 100644 index 00000000..afa3cd4e --- /dev/null +++ b/src/Domain/Model/PayPalSubscription.php @@ -0,0 +1,25 @@ +getInterval()->isRecurring() ) { + throw new \DomainException( self::class . ' can only be used for recurring payments' ); + } + $trimmedSubscriptionId = trim( $subscriptionId ); + if ( empty( $trimmedSubscriptionId ) ) { + throw new \DomainException( 'Subscription ID must not be empty' ); + } + parent::__construct( $payment ); + $this->subscriptionId = $subscriptionId; + } + + public function getSubscriptionId(): string { + return $this->subscriptionId; + } + +} diff --git a/src/Domain/Model/PaymentInterval.php b/src/Domain/Model/PaymentInterval.php index 1239f884..7c855f5b 100644 --- a/src/Domain/Model/PaymentInterval.php +++ b/src/Domain/Model/PaymentInterval.php @@ -9,4 +9,24 @@ enum PaymentInterval: int { case Quarterly = 3; case HalfYearly = 6; case Yearly = 12; + + private const ALLOWED_INTERVALS = [ + 'OneTime' => PaymentInterval::OneTime, + 'Monthly' => PaymentInterval::Monthly, + 'Quarterly' => PaymentInterval::Quarterly, + 'HalfYearly' => PaymentInterval::HalfYearly, + 'Yearly' => PaymentInterval::Yearly, + ]; + + public function isRecurring(): bool { + return $this !== self::OneTime; + } + + public static function fromString( string $interval ): self { + if ( isset( self::ALLOWED_INTERVALS[ $interval ] ) ) { + return self::ALLOWED_INTERVALS[ $interval ]; + } else { + throw new \OutOfBoundsException( 'Invalid payment interval given' ); + } + } } diff --git a/src/Domain/Model/SofortPayment.php b/src/Domain/Model/SofortPayment.php index 9d365b87..779a8bfa 100644 --- a/src/Domain/Model/SofortPayment.php +++ b/src/Domain/Model/SofortPayment.php @@ -34,7 +34,7 @@ class SofortPayment extends Payment implements BookablePayment { private ?DateTimeImmutable $valuationDate = null; private function __construct( int $id, Euro $amount, PaymentInterval $interval, ?PaymentReferenceCode $paymentReference ) { - if ( $interval !== PaymentInterval::OneTime ) { + if ( $interval->isRecurring() ) { throw new \InvalidArgumentException( "Provided payment interval must be 0 (= one time payment) for Sofort payments." ); } parent::__construct( $id, $amount, $interval, self::PAYMENT_METHOD ); diff --git a/src/Domain/PayPalPaymentIdentifierRepository.php b/src/Domain/PayPalPaymentIdentifierRepository.php new file mode 100644 index 00000000..1018f72f --- /dev/null +++ b/src/Domain/PayPalPaymentIdentifierRepository.php @@ -0,0 +1,10 @@ + new Sofort( $this->sofortConfig, $this->sofortClient, $payment ), - $payment instanceof CreditCardPayment => new CreditCard( $this->creditCardConfig, $payment ), - $payment instanceof PayPalPayment => new PayPal( $this->payPalConfig, $payment ), - default => new NullGenerator(), - }; - } -} diff --git a/src/Domain/PaymentUrlGenerator/RequestContext.php b/src/Domain/PaymentUrlGenerator/RequestContext.php deleted file mode 100644 index 9717f64e..00000000 --- a/src/Domain/PaymentUrlGenerator/RequestContext.php +++ /dev/null @@ -1,28 +0,0 @@ -config = $config; - $this->client = $client; - $this->payment = $payment; - } - - public function generateUrl( RequestContext $requestContext ): string { - $request = new Request(); - $request->setAmount( $this->payment->getAmount() ); - $request->setCurrencyCode( self::CURRENCY ); - $request->setReasons( [ - $this->config->getTranslatableDescription()->getText( - $this->payment->getAmount(), - $this->payment->getInterval() - ), - $this->payment->getPaymentReferenceCode() - ] ); - $request->setSuccessUrl( - $this->config->getReturnUrl() . '?' . http_build_query( - [ - 'id' => $requestContext->itemId, - 'accessToken' => $requestContext->accessToken - ] - ) - ); - $request->setAbortUrl( $this->config->getCancelUrl() ); - $request->setNotificationUrl( - $this->config->getNotificationUrl() . '?' . http_build_query( - [ - 'id' => $requestContext->itemId, - 'updateToken' => $requestContext->updateToken - ] - ) - ); - $request->setLocale( $this->config->getLocale() ); - - try { - $response = $this->client->get( $request ); - } catch ( RuntimeException $exception ) { - throw new RuntimeException( 'Could not generate Sofort URL: ' . $exception->getMessage() ); - } - - return $response->getPaymentUrl(); - } -} diff --git a/src/Domain/PaymentUrlGenerator/UrlGeneratorFactory.php b/src/Domain/PaymentUrlGenerator/UrlGeneratorFactory.php deleted file mode 100644 index c9f27682..00000000 --- a/src/Domain/PaymentUrlGenerator/UrlGeneratorFactory.php +++ /dev/null @@ -1,10 +0,0 @@ -itemId, + null, + $this->invoiceId, + $this->firstName, + $this->lastName + ); + } +} diff --git a/src/Domain/UrlGenerator/PaymentCompletionURLGenerator.php b/src/Domain/UrlGenerator/PaymentCompletionURLGenerator.php new file mode 100644 index 00000000..c9d4d4ec --- /dev/null +++ b/src/Domain/UrlGenerator/PaymentCompletionURLGenerator.php @@ -0,0 +1,17 @@ + $context + * @param \Exception|null $e + * + * @return PayPalAPIException + */ + private function createLoggedException( string $errorMessage, array $context, \Exception $e = null ): PayPalAPIException { + $this->logger->error( $errorMessage, $context ); + throw new PayPalAPIException( $errorMessage, 0, $e ); + } + + public function listProducts(): array { + $productResponse = $this->client->request( + 'GET', + self::ENDPOINT_PRODUCTS, + [ RequestOptions::HEADERS => [ + 'Authorization' => $this->getAuthHeader() + ] ] + ); + + $serverResponse = $productResponse->getBody()->getContents(); + $jsonProductResponse = $this->safelyDecodeJSON( $serverResponse ); + + if ( !is_array( $jsonProductResponse ) || !isset( $jsonProductResponse['products'] ) ) { + throw $this->createLoggedException( "Listing products failed!", [ "serverResponse" => $serverResponse ] ); + } + + if ( isset( $jsonProductResponse['total_pages'] ) && $jsonProductResponse['total_pages'] > 1 ) { + throw $this->createLoggedException( + "Paging is not supported because we don't have that many products!", + [ "serverResponse" => $serverResponse ] + ); + } + + $products = []; + foreach ( $jsonProductResponse['products'] as $product ) { + $products[] = new Product( $product['id'], $product['name'], $product['description'] ?? '' ); + } + return $products; + } + + public function createProduct( Product $product ): Product { + $response = $this->sendPOSTRequest( self::ENDPOINT_PRODUCTS, $product->toJSON() ); + + $serverResponse = $response->getBody()->getContents(); + $jsonProductResponse = $this->safelyDecodeJSON( $serverResponse ); + + if ( !is_array( $jsonProductResponse ) || empty( $jsonProductResponse['name'] ) || empty( $jsonProductResponse['id'] ) ) { + throw $this->createLoggedException( + 'Server did not send product data back', + [ "serverResponse" => $serverResponse ] + ); + } + return new Product( + $jsonProductResponse['id'], + $jsonProductResponse['name'], + $jsonProductResponse['description'] ?? null + ); + } + + public function listSubscriptionPlansForProduct( string $productId ): array { + $planResponse = $this->client->request( + 'GET', + self::ENDPOINT_SUBSCRIPTION_PLANS, + [ + RequestOptions::HEADERS => [ + 'Authorization' => $this->getAuthHeader(), + 'Accept' => "application/json", + 'Prefer' => 'return=representation' + ], + RequestOptions::QUERY => [ 'product_id' => $productId ] + ] + ); + + $serverResponse = $planResponse->getBody()->getContents(); + $jsonPlanResponse = $this->safelyDecodeJSON( $serverResponse ); + + if ( !is_array( $jsonPlanResponse ) || !isset( $jsonPlanResponse['plans'] ) ) { + throw $this->createLoggedException( "Listing subscription plans failed!", [ "serverResponse" => $serverResponse ] ); + } + + if ( isset( $jsonPlanResponse['total_pages'] ) && $jsonPlanResponse['total_pages'] > 1 ) { + throw $this->createLoggedException( + "Paging is not supported because each product should not have more than 4 payment intervals!", + [ "serverResponse" => $serverResponse ] + ); + } + + $plans = []; + foreach ( $jsonPlanResponse['plans'] as $plan ) { + $plans[] = SubscriptionPlan::from( $plan ); + } + return $plans; + } + + /** + * @param SubscriptionPlan $subscriptionPlan + * @return SubscriptionPlan + */ + public function createSubscriptionPlanForProduct( SubscriptionPlan $subscriptionPlan ): SubscriptionPlan { + $response = $this->sendPOSTRequest( self::ENDPOINT_SUBSCRIPTION_PLANS, $subscriptionPlan->toJSON() ); + + $serverResponse = $response->getBody()->getContents(); + $jsonSubscriptionPlanResponse = $this->safelyDecodeJSON( $serverResponse ); + + try { + return SubscriptionPlan::from( $jsonSubscriptionPlanResponse ); + } catch ( PayPalAPIException $e ) { + throw $this->createLoggedException( + "Server returned faulty subscription plan data: " . $e->getMessage(), + [ + "serverResponse" => $serverResponse, + "error" => $e->getMessage() + ], + $e + ); + } + } + + public function createSubscription( SubscriptionParameters $subscriptionParameters ): Subscription { + $response = $this->sendPOSTRequest( self::ENDPOINT_SUBSCRIPTION, $subscriptionParameters->toJSON() ); + + $serverResponse = $response->getBody()->getContents(); + $jsonSubscriptionResponse = $this->safelyDecodeJSON( $serverResponse ); + + return Subscription::from( $jsonSubscriptionResponse ); + } + + public function createOrder( OrderParameters $orderParameters ): Order { + $response = $this->sendPOSTRequest( self::ENDPOINT_ORDER, $orderParameters->toJSON() ); + + $serverResponse = $response->getBody()->getContents(); + $jsonOrderResponse = $this->safelyDecodeJSON( $serverResponse ); + + return Order::from( $jsonOrderResponse ); + } + + private function sendPOSTRequest( string $endpointURI, string $requestBody ): ResponseInterface { + try { + return $this->client->request( + 'POST', + $endpointURI, + [ + RequestOptions::HEADERS => [ + 'Authorization' => $this->getAuthHeader(), + 'Content-Type' => "application/json", + 'Accept' => "application/json", + 'Prefer' => 'return=representation' + ], + RequestOptions::BODY => $requestBody + ] + ); + } catch ( BadResponseException $e ) { + throw $this->createLoggedException( + "Server rejected request: " . $e->getMessage(), + [ + "serverResponse" => $e->getResponse()->getBody()->getContents(), + "error" => $e->getMessage(), + "requestBody" => $requestBody + ], + ); + } + } + + /** + * @param string $serverResponse + * + * @return array decoded JSON + * @phpstan-ignore-next-line + */ + private function safelyDecodeJSON( string $serverResponse ): array { + try { + $decodedJSONResponse = json_decode( $serverResponse, true, 512, JSON_THROW_ON_ERROR ); + } catch ( JsonException $e ) { + throw $this->createLoggedException( + "Malformed JSON", + [ + "serverResponse" => $serverResponse, + "error" => $e->getMessage() + ], + $e + ); + } + + if ( !is_array( $decodedJSONResponse ) ) { + throw new PayPalAPIException( 'array expected' ); + } + + return $decodedJSONResponse; + } + + private function getAuthHeader(): string { + return 'Basic ' . base64_encode( $this->clientId . ':' . $this->clientSecret ); + } + +} diff --git a/src/Services/PayPal/Model/Order.php b/src/Services/PayPal/Model/Order.php new file mode 100644 index 00000000..3f59a6eb --- /dev/null +++ b/src/Services/PayPal/Model/Order.php @@ -0,0 +1,46 @@ + $apiResponse The PayPal API response for "create Order" + * @return self + */ + public static function from( array $apiResponse ): self { + if ( !isset( $apiResponse['id'] ) ) { + throw new PayPalAPIException( 'Field "id" is required!' ); + } + + if ( !is_string( $apiResponse['id'] ) || $apiResponse['id'] === '' ) { + throw new PayPalAPIException( "Id is not a valid string!" ); + } + + if ( !isset( $apiResponse['links'] ) || !is_array( $apiResponse['links'] ) ) { + throw new PayPalAPIException( 'Fields must contain array with links!' ); + } + + return new self( $apiResponse['id'], self::getUrlFromLinks( $apiResponse['links'] ) ); + } + + /** + * @param array{"rel":string,"href":string}[] $links + * @return string + */ + private static function getUrlFromLinks( array $links ): string { + foreach ( $links as $link ) { + if ( $link['rel'] === 'payer-action' ) { + return $link['href']; + } + } + throw new PayPalAPIException( "Link array did not contain approve link!" ); + } +} diff --git a/src/Services/PayPal/Model/OrderParameters.php b/src/Services/PayPal/Model/OrderParameters.php new file mode 100644 index 00000000..a18b768b --- /dev/null +++ b/src/Services/PayPal/Model/OrderParameters.php @@ -0,0 +1,67 @@ + 'EUR', + 'value' => $this->amount->getEuroString(), + ]; + + return json_encode( + [ + 'purchase_units' => + [ + [ + 'reference_id' => $this->orderId, + 'invoice_id' => $this->invoiceId, + 'items' => + [ + [ + 'name' => $this->itemName, + 'quantity' => '1', + 'category' => 'DONATION', + 'unit_amount' => $euroAmount + ], + ], + 'amount' => + [ + ...$euroAmount, + 'breakdown' => + [ + 'item_total' => $euroAmount + ], + ], + ], + ], + 'intent' => 'CAPTURE', + 'payment_source' => [ + 'paypal' => [ + 'experience_context' => [ + 'brand_name' => 'Wikimedia Deutschland', + 'user_action' => 'PAY_NOW', + "shipping_preference" => 'NO_SHIPPING', + 'return_url' => $this->returnUrl, + 'cancel_url' => $this->cancelUrl, + ] + ] + ] + ], + JSON_THROW_ON_ERROR ); + } +} diff --git a/src/Services/PayPal/Model/PayPalAPIException.php b/src/Services/PayPal/Model/PayPalAPIException.php new file mode 100644 index 00000000..90cb80b1 --- /dev/null +++ b/src/Services/PayPal/Model/PayPalAPIException.php @@ -0,0 +1,9 @@ +name ) === '' || trim( $this->id ) === '' ) { + throw new \UnexpectedValueException( 'Name and Id must not be empty' ); + } + } + + public function toJSON(): string { + return json_encode( [ + "name" => $this->name, + "id" => $this->id, + "description" => $this->description, + "category" => "NONPROFIT", + "type" => "SERVICE" + ], JSON_THROW_ON_ERROR ); + } +} diff --git a/src/Services/PayPal/Model/Subscription.php b/src/Services/PayPal/Model/Subscription.php new file mode 100644 index 00000000..cdf5604a --- /dev/null +++ b/src/Services/PayPal/Model/Subscription.php @@ -0,0 +1,60 @@ + $apiResponse The PayPal API response for "create subscription" + * @return self + */ + public static function from( array $apiResponse ): self { + if ( !isset( $apiResponse['id'] ) || !isset( $apiResponse['start_time'] ) ) { + throw new PayPalAPIException( 'Fields "id" and "start_time" are required' ); + } + + if ( !is_string( $apiResponse['id'] ) ) { + throw new PayPalAPIException( "Id is not a valid string!" ); + } + + if ( !is_string( $apiResponse['start_time'] ) || $apiResponse['start_time'] === '' ) { + throw new PayPalAPIException( 'Malformed date formate for start_time' ); + } + + try { + $subscriptionStart = new DateTimeImmutable( $apiResponse['start_time'] ); + } catch ( \Exception $e ) { + throw new PayPalAPIException( 'Malformed date formate for start_time', 0, $e ); + } + + if ( !isset( $apiResponse['links'] ) || !is_array( $apiResponse['links'] ) ) { + throw new PayPalAPIException( 'Fields must contain array with links!' ); + } + + $url = self::getUrlFromLinks( $apiResponse['links'] ); + + return new Subscription( $apiResponse['id'], $subscriptionStart, $url ); + } + + /** + * @param array{"rel":string,"href":string}[] $links + * @return string + */ + private static function getUrlFromLinks( array $links ): string { + foreach ( $links as $link ) { + if ( $link['rel'] === 'approve' ) { + return $link['href']; + } + } + throw new PayPalAPIException( 'Link array did not contain approval link!' ); + } +} diff --git a/src/Services/PayPal/Model/SubscriptionParameters.php b/src/Services/PayPal/Model/SubscriptionParameters.php new file mode 100644 index 00000000..5a9f7e27 --- /dev/null +++ b/src/Services/PayPal/Model/SubscriptionParameters.php @@ -0,0 +1,45 @@ +startTime === null ? + [] : + [ "start_time" => $this->startTime->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' ) ]; + + return json_encode( + [ + "plan_id" => $this->subscriptionPlan->id, + ...$start_time, + "quantity" => "1", + "plan" => [ + "billing_cycles" => SubscriptionPlan::getBillingCycle( + $this->subscriptionPlan->monthlyInterval->value, + $this->amount->getEuroString() + ) + ], + "application_context" => [ + "brand_name" => "Wikimedia Deutschland", + "shipping_preference" => "NO_SHIPPING", + "return_url" => $this->returnUrl, + "cancel_url" => $this->cancelUrl + ] + ], + JSON_THROW_ON_ERROR + ); + } +} diff --git a/src/Services/PayPal/Model/SubscriptionPlan.php b/src/Services/PayPal/Model/SubscriptionPlan.php new file mode 100644 index 00000000..da8fd969 --- /dev/null +++ b/src/Services/PayPal/Model/SubscriptionPlan.php @@ -0,0 +1,114 @@ +name ) === '' ) { + throw new \UnexpectedValueException( 'Subscription plan name must not be empty' ); + } + } + + /** + * @param array $apiReponse A single plan item from the PayPal API response + * @return SubscriptionPlan + */ + public static function from( array $apiReponse ): SubscriptionPlan { + // Theoretically, we'd want to check name, product_id, and id in $apiData, + // but the billing_cycles check should be sufficient to detect broken data from the PayPal API + + if ( empty( $apiReponse['billing_cycles'] ) || !is_array( $apiReponse['billing_cycles'] ) || count( $apiReponse['billing_cycles'] ) !== 1 ) { + throw new PayPalAPIException( 'Wrong billing cycle data' ); + } + $billingCycle = $apiReponse['billing_cycles'][0]; + + if ( !isset( $billingCycle['frequency'] ) || !isset( $billingCycle['frequency']['interval_count'] ) ) { + throw new PayPalAPIException( 'Wrong frequency data in billing cycle' ); + } + $frequency = $billingCycle['frequency']; + + if ( ( $frequency['interval_unit'] ?? '' ) !== 'MONTH' ) { + throw new PayPalAPIException( 'interval_unit must be MONTH' ); + } + $monthlyInterval = PaymentInterval::from( intval( $frequency['interval_count'] ) ); + $description = $apiReponse['description'] ?? ''; + + // Make static typechecker happy, using strval on mixed throws errors + if ( + !is_scalar( $apiReponse['name'] ) || + !is_scalar( $apiReponse['product_id'] ) || + !is_scalar( $apiReponse['id'] ) || + !is_scalar( $description ) + ) { + throw new UnexpectedValueException( 'Scalar value expected' ); + } + + return new SubscriptionPlan( + strval( $apiReponse['name'] ), + strval( $apiReponse['product_id'] ), + $monthlyInterval, + strval( $apiReponse['id'] ), + strval( $description ), + ); + } + + /** + * @phpstan-ignore-next-line + */ + public static function getBillingCycle( int $monthlyInterval, string $amount ): array { + return [ [ + "sequence" => 1, + "pricing_scheme" => [ + "fixed_price" => [ + "value" => $amount, + "currency_code" => "EUR" + ] + ], + "tenure_type" => "REGULAR", + "frequency" => [ + "interval_unit" => "MONTH", + "interval_count" => $monthlyInterval + ], + "total_cycles" => 0 + ] ]; + } + + public function toJSON(): string { + return json_encode( [ + "name" => $this->name, + "product_id" => $this->productId, + "description" => $this->description, + // pricing_scheme with amount is required by the api, but value is set to 1 EUR + // subscriptions must override this with the actual value of the donation/membership fee + "billing_cycles" => self::getBillingCycle( $this->monthlyInterval->value, "1" ), + "payment_preferences" => [ + "auto_bill_outstanding" => true, + "setup_fee_failure_action" => "CONTINUE", + "payment_failure_threshold" => 0, + "setup_fee" => [ + "currency_code" => "EUR", + "value" => "0" + ] + ] + ], JSON_THROW_ON_ERROR ); + } + +} diff --git a/src/Services/PayPal/PayPalPaymentProviderAdapter.php b/src/Services/PayPal/PayPalPaymentProviderAdapter.php new file mode 100644 index 00000000..8357a66e --- /dev/null +++ b/src/Services/PayPal/PayPalPaymentProviderAdapter.php @@ -0,0 +1,128 @@ +checkIfPaymentIsPayPalPayment( $payment ); + + if ( $payment->getInterval()->isRecurring() ) { + $subscription = $this->createSubscriptionWithAPI( $payment, $domainSpecificContext ); + $identifier = new PayPalSubscription( $payment, $subscription->id ); + } else { + $order = $this->createOrderWithAPI( $payment, $domainSpecificContext ); + $identifier = new PayPalOrder( $payment, $order->id ); + } + + $this->paymentIdentifierRepository->storePayPalIdentifier( $identifier ); + return $payment; + } + + public function modifyPaymentUrlGenerator( PaymentCompletionURLGenerator $paymentProviderURLGenerator, DomainSpecificContext $domainSpecificContext ): PaymentCompletionURLGenerator { + if ( $paymentProviderURLGenerator instanceof LegacyPayPalURLGenerator ) { + // All logic for domain-specific information and authentication encapsulated in LegacyPayPalURLGenerator + return $paymentProviderURLGenerator; + } + if ( !( $paymentProviderURLGenerator instanceof IncompletePayPalURLGenerator ) ) { + throw new \LogicException( sprintf( + 'Expected instance of %s, got %s', + IncompletePayPalURLGenerator::class, + get_class( $paymentProviderURLGenerator ) + ) ); + } + + $payment = $paymentProviderURLGenerator->payment; + if ( $payment->getInterval()->isRecurring() ) { + $subscription = $this->createSubscriptionWithAPI( $payment, $domainSpecificContext ); + return new PayPalURLGenerator( $subscription->confirmationLink ); + } else { + $order = $this->createOrderWithAPI( $payment, $domainSpecificContext ); + return new PayPalURLGenerator( $order->confirmationLink ); + } + } + + /** + * Create subscription with API, but use subscription property as cache, to avoid multiple API calls + */ + private function createSubscriptionWithAPI( PayPalPayment $payment, DomainSpecificContext $domainSpecificContext ): Subscription { + if ( $this->subscription === null ) { + $subscriptionPlan = $this->config->subscriptionPlanMap[ $payment->getInterval()->name ]; + $params = new SubscriptionParameters( + $subscriptionPlan, + $payment->getAmount(), + $this->urlAuthenticator->addAuthenticationTokensToApplicationUrl( $this->config->returnURL ), + $this->urlAuthenticator->addAuthenticationTokensToApplicationUrl( $this->config->cancelURL ), + $domainSpecificContext->startTimeForRecurringPayment + ); + $this->subscription = $this->paypalAPI->createSubscription( $params ); + } + return $this->subscription; + } + + private function createOrderWithAPI( PayPalPayment $payment, DomainSpecificContext $domainSpecificContext, ): Model\Order { + if ( $this->order === null ) { + $params = new OrderParameters( + (string)$domainSpecificContext->itemId, + $domainSpecificContext->invoiceId, + $this->config->productName, + $payment->getAmount(), + $this->urlAuthenticator->addAuthenticationTokensToApplicationUrl( $this->config->returnURL ), + $this->urlAuthenticator->addAuthenticationTokensToApplicationUrl( $this->config->cancelURL ) + ); + $this->order = $this->paypalAPI->createOrder( $params ); + } + return $this->order; + } + + /** + * @phpstan-assert PayPalPayment $payment + */ + private function checkIfPaymentIsPayPalPayment( Payment $payment ): void { + if ( !( $payment instanceof PayPalPayment ) ) { + throw new \LogicException( sprintf( + '%s only accepts %s, got %s', + self::class, + PayPalPayment::class, + get_class( $payment ) + ) ); + } + } + +} diff --git a/src/Services/PayPal/PayPalPaymentProviderAdapterConfig.php b/src/Services/PayPal/PayPalPaymentProviderAdapterConfig.php new file mode 100644 index 00000000..baa4db85 --- /dev/null +++ b/src/Services/PayPal/PayPalPaymentProviderAdapterConfig.php @@ -0,0 +1,31 @@ + $subscriptionPlanMap (PaymentInterval names ("Monthly", "HalfYearly") as keys) + */ + public function __construct( + public readonly string $productName, + public readonly string $returnURL, + public readonly string $cancelURL, + public readonly array $subscriptionPlanMap + ) { + } +} diff --git a/src/Services/PayPal/PayPalPaymentProviderAdapterConfigFactory.php b/src/Services/PayPal/PayPalPaymentProviderAdapterConfigFactory.php new file mode 100644 index 00000000..d5369cfc --- /dev/null +++ b/src/Services/PayPal/PayPalPaymentProviderAdapterConfigFactory.php @@ -0,0 +1,48 @@ + + */ + private static function createSubscriptionPlans( array $subscriptionPlansConfig, string $productId ): array { + $plans = []; + foreach ( $subscriptionPlansConfig as $subscriptionPlanValues ) { + $interval = PaymentInterval::fromString( $subscriptionPlanValues['interval'] ); + $plans[$interval->name] = new SubscriptionPlan( + $subscriptionPlanValues['name'], + $productId, + $interval, + $subscriptionPlanValues['id'] + ); + } + return $plans; + } +} diff --git a/src/Services/PayPal/PayPalPaymentProviderAdapterConfigReader.php b/src/Services/PayPal/PayPalPaymentProviderAdapterConfigReader.php new file mode 100644 index 00000000..d79473c6 --- /dev/null +++ b/src/Services/PayPal/PayPalPaymentProviderAdapterConfigReader.php @@ -0,0 +1,51 @@ +processConfiguration( + $schema, + [ $config ] + ); + + self::checkProductAndSubscriptionPlanIdsAreUnique( $config ); + + return $config; + } + + private static function checkProductAndSubscriptionPlanIdsAreUnique( array $config ): void { + $allExistingProductIds = []; + $allExistingSubscriptionPlanIds = []; + foreach ( $config as $currentProduct ) { + foreach ( $currentProduct as $currentConfig ) { + $allExistingProductIds[] = $currentConfig['product_id']; + foreach ( $currentConfig['subscription_plans'] as $currentPlanConfig ) { + $allExistingSubscriptionPlanIds[] = $currentPlanConfig['id']; + } + } + } + $uniqueProductIds = array_unique( $allExistingProductIds ); + if ( count( $allExistingProductIds ) !== count( $uniqueProductIds ) ) { + throw new \DomainException( "All product IDs in the configuration file must be unique!" ); + } + + $uniqueSubscriptionPlanIds = array_unique( $allExistingSubscriptionPlanIds ); + if ( count( $uniqueSubscriptionPlanIds ) !== count( $allExistingSubscriptionPlanIds ) ) { + throw new \DomainException( "All subscription plan IDs in the configuration file must be unique!" ); + } + } +} diff --git a/src/Services/PayPal/PayPalPaymentProviderAdapterConfigSchema.php b/src/Services/PayPal/PayPalPaymentProviderAdapterConfigSchema.php new file mode 100644 index 00000000..ab176512 --- /dev/null +++ b/src/Services/PayPal/PayPalPaymentProviderAdapterConfigSchema.php @@ -0,0 +1,47 @@ +getRootNode() + ->arrayPrototype() + ->requiresAtLeastOneElement() + ->arrayPrototype() + ->children() + ->scalarNode( 'product_id' )->isRequired()->end() + ->scalarNode( 'product_name' )->isRequired()->end() + ->scalarNode( 'return_url' )->isRequired()->end() + ->scalarNode( 'cancel_url' )->isRequired()->end() + ->arrayNode( 'subscription_plans' ) + ->isRequired() + ->arrayPrototype() + ->children() + ->scalarNode( 'id' )->isRequired()->end() + ->scalarNode( 'name' )->isRequired()->end() + ->enumNode( 'interval' ) + ->isRequired() + ->values( [ + PaymentInterval::Monthly->name, + PaymentInterval::Quarterly->name, + PaymentInterval::HalfYearly->name, + PaymentInterval::Yearly->name + ] ) + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } + +} diff --git a/src/Services/PayPal/PaypalAPI.php b/src/Services/PayPal/PaypalAPI.php new file mode 100644 index 00000000..d38b9ad8 --- /dev/null +++ b/src/Services/PayPal/PaypalAPI.php @@ -0,0 +1,47 @@ +paypalAPI, + $this->payPalAdapterConfig, + $this->paymentIdentifierRepository, + $authenticator + ); + } + return new DefaultPaymentProviderAdapter(); + } +} diff --git a/src/Services/PaymentURLFactory.php b/src/Services/PaymentURLFactory.php new file mode 100644 index 00000000..f8ceef99 --- /dev/null +++ b/src/Services/PaymentURLFactory.php @@ -0,0 +1,65 @@ + new SofortURLGenerator( $this->sofortConfig, $this->sofortClient, $authenticator, $payment ), + $payment instanceof CreditCardPayment => new CreditCardURLGenerator( $this->creditCardConfig, $authenticator, $payment ), + $payment instanceof PayPalPayment => $this->createPayPalUrlGenerator( $payment, $authenticator ), + $payment instanceof DirectDebitPayment => new ConfirmationPageUrlGenerator( $this->confirmationPageUrl, $authenticator ), + $payment instanceof BankTransferPayment => new ConfirmationPageUrlGenerator( $this->confirmationPageUrl, $authenticator ), + default => throw new \InvalidArgumentException( 'Unknown payment type: ' . get_class( $payment ) ), + }; + } + + public function createPayPalUrlGenerator( PayPalPayment $payPalPayment, URLAuthenticator $authenticator ): PaymentCompletionURLGenerator { + // TODO: Remove when the application has switched completely to the PayPal API, + // and we don't need the feature flag any more + // See https://phabricator.wikimedia.org/T329159 + if ( $this->useLegacyPayPalUrlGenerator ) { + return new LegacyPayPalURLGenerator( $this->legacyPayPalConfig, $authenticator, $payPalPayment ); + } + + // The IncompletePayPalURLGenerator will be replaced inside the use case with a PayPalURLGenerator, + // we need a default here to fulfill the type requirements + // TODO: When one-time payments are supported, always return IncompletePayPalURLGenerator + // See https://phabricator.wikimedia.org/T344263 + if ( $payPalPayment->getInterval()->isRecurring() ) { + return new IncompletePayPalURLGenerator( $payPalPayment ); + } else { + return new LegacyPayPalURLGenerator( $this->legacyPayPalConfig, $authenticator, $payPalPayment ); + } + } +} diff --git a/src/Services/PaymentUrlGenerator/ConfirmationPageUrlGenerator.php b/src/Services/PaymentUrlGenerator/ConfirmationPageUrlGenerator.php new file mode 100644 index 00000000..0fb1721d --- /dev/null +++ b/src/Services/PaymentUrlGenerator/ConfirmationPageUrlGenerator.php @@ -0,0 +1,24 @@ +urlAuthenticator->addAuthenticationTokensToApplicationUrl( $this->confirmationPageUrl ); + } +} diff --git a/src/Domain/PaymentUrlGenerator/CreditCard.php b/src/Services/PaymentUrlGenerator/CreditCardURLGenerator.php similarity index 53% rename from src/Domain/PaymentUrlGenerator/CreditCard.php rename to src/Services/PaymentUrlGenerator/CreditCardURLGenerator.php index 621253bb..9eed7844 100644 --- a/src/Domain/PaymentUrlGenerator/CreditCard.php +++ b/src/Services/PaymentUrlGenerator/CreditCardURLGenerator.php @@ -2,25 +2,23 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator; use WMDE\Fundraising\PaymentContext\Domain\Model\CreditCardPayment; +use WMDE\Fundraising\PaymentContext\Domain\UrlGenerator\DomainSpecificContext; +use WMDE\Fundraising\PaymentContext\Domain\UrlGenerator\PaymentCompletionURLGenerator; +use WMDE\Fundraising\PaymentContext\Services\URLAuthenticator; -/** - * @license GPL-2.0-or-later - * @author Kai Nissen < kai.nissen@wikimedia.de > - */ -class CreditCard implements PaymentProviderURLGenerator { +class CreditCardURLGenerator implements PaymentCompletionURLGenerator { - private CreditCardConfig $config; - private CreditCardPayment $payment; - - public function __construct( CreditCardConfig $config, CreditCardPayment $payment ) { - $this->config = $config; - $this->payment = $payment; + public function __construct( + private readonly CreditCardURLGeneratorConfig $config, + private readonly URLAuthenticator $urlAuthenticator, + private readonly CreditCardPayment $payment, + ) { } - public function generateUrl( RequestContext $requestContext ): string { + public function generateUrl( DomainSpecificContext $requestContext ): string { $baseUrl = $this->config->getBaseUrl(); $params = [ 'project' => $this->config->getProjectId(), @@ -30,12 +28,11 @@ public function generateUrl( RequestContext $requestContext ): string { 'mp_user_surname' => $requestContext->lastName, 'sid' => $requestContext->itemId, 'gfx' => $this->config->getLogo(), - 'token' => $requestContext->accessToken, - 'utoken' => $requestContext->updateToken, 'amount' => $this->payment->getAmount()->getEuroCents(), 'theme' => $this->config->getTheme(), 'producttype' => 'fee', 'lang' => $this->config->getLocale(), + ...$this->urlAuthenticator->getAuthenticationTokensForPaymentProviderUrl( self::class, [ 'token', 'utoken' ] ) ]; if ( $this->config->isTestMode() ) { $params['testmode'] = '1'; diff --git a/src/Domain/PaymentUrlGenerator/CreditCardConfig.php b/src/Services/PaymentUrlGenerator/CreditCardURLGeneratorConfig.php similarity index 96% rename from src/Domain/PaymentUrlGenerator/CreditCardConfig.php rename to src/Services/PaymentUrlGenerator/CreditCardURLGeneratorConfig.php index 40111c75..bf6e4ec2 100644 --- a/src/Domain/PaymentUrlGenerator/CreditCardConfig.php +++ b/src/Services/PaymentUrlGenerator/CreditCardURLGeneratorConfig.php @@ -2,9 +2,9 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator; -class CreditCardConfig { +class CreditCardURLGeneratorConfig { private const CONFIG_KEY_BASE_URL = 'base-url'; private const CONFIG_KEY_PROJECT_ID = 'project-id'; diff --git a/src/Services/PaymentUrlGenerator/IncompletePayPalURLGenerator.php b/src/Services/PaymentUrlGenerator/IncompletePayPalURLGenerator.php new file mode 100644 index 00000000..4f45ce06 --- /dev/null +++ b/src/Services/PaymentUrlGenerator/IncompletePayPalURLGenerator.php @@ -0,0 +1,24 @@ +config = $config; - $this->payment = $payment; + public function __construct( + private readonly LegacyPayPalURLGeneratorConfig $config, + private readonly URLAuthenticator $urlAuthenticator, + private readonly PayPalPayment $payment + ) { } - public function generateUrl( RequestContext $requestContext ): string { + public function generateUrl( DomainSpecificContext $requestContext ): string { $params = array_merge( $this->getIntervalDependentParameters( $this->payment->getAmount(), $this->payment->getInterval()->value ), $this->getIntervalAgnosticParameters( $requestContext->itemId, $requestContext->invoiceId, - $requestContext->updateToken, - $requestContext->accessToken ), + ), $this->getPaymentDelayParameters() ); @@ -39,11 +50,9 @@ public function generateUrl( RequestContext $requestContext ): string { /** * @param int $itemId * @param string $invoiceId - * @param string $updateToken - * @param string $accessToken * @return array */ - private function getIntervalAgnosticParameters( int $itemId, string $invoiceId, string $updateToken, string $accessToken ): array { + private function getIntervalAgnosticParameters( int $itemId, string $invoiceId ): array { return [ 'business' => $this->config->getPayPalAccountAddress(), 'currency_code' => 'EUR', @@ -53,18 +62,19 @@ private function getIntervalAgnosticParameters( int $itemId, string $invoiceId, 'invoice' => $invoiceId, 'notify_url' => $this->config->getNotifyUrl(), 'cancel_return' => $this->config->getCancelUrl(), - 'return' => $this->config->getReturnUrl() . '?id=' . $itemId . '&accessToken=' . $accessToken, - 'custom' => json_encode( - [ - 'sid' => $itemId, - 'utoken' => $updateToken - ] + 'return' => $this->urlAuthenticator->addAuthenticationTokensToApplicationUrl( + $this->config->getReturnUrl() . '?id=' . $itemId + ), + ...$this->urlAuthenticator->getAuthenticationTokensForPaymentProviderUrl( + self::class, + [ 'custom' ] ) ]; } /** * @return array + * @deprecated The "Trial Period" was an attempt at using PayPal for memberships. We'll use the PayPal API with subscriptions instead. */ private function getPaymentDelayParameters(): array { if ( $this->config->getDelayInDays() > 0 ) { @@ -117,6 +127,7 @@ private function getSubscriptionParams( Euro $amount, int $interval ): array { * @param int $delayInDays * * @return array + * @deprecated The "Trial Period" was an attempt at using PayPal for memberships. We'll use the PayPal API with subscriptions instead. */ private function getDelayedSubscriptionParams( int $delayInDays ): array { return [ diff --git a/src/Domain/PaymentUrlGenerator/PayPalConfig.php b/src/Services/PaymentUrlGenerator/LegacyPayPalURLGeneratorConfig.php similarity index 93% rename from src/Domain/PaymentUrlGenerator/PayPalConfig.php rename to src/Services/PaymentUrlGenerator/LegacyPayPalURLGeneratorConfig.php index 8603b211..db2420c8 100644 --- a/src/Domain/PaymentUrlGenerator/PayPalConfig.php +++ b/src/Services/PaymentUrlGenerator/LegacyPayPalURLGeneratorConfig.php @@ -2,15 +2,14 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator; use RuntimeException; /** - * @license GPL-2.0-or-later - * @author Kai Nissen < kai.nissen@wikimedia.de > + * @deprecated */ -class PayPalConfig { +class LegacyPayPalURLGeneratorConfig { public const CONFIG_KEY_ACCOUNT_ADDRESS = 'account-address'; public const CONFIG_KEY_LOCALE = 'locale'; @@ -45,7 +44,7 @@ private function __construct( string $payPalAccountAddress, string $locale, stri * @param array{ 'account-address': string, 'locale' : string, 'base-url': string, 'notify-url': string, 'return-url': string, 'cancel-url': string, 'delay-in-days'?: int } $config * @param TranslatableDescription $translatableDescription * - * @return PayPalConfig + * @return LegacyPayPalURLGeneratorConfig * @throws RuntimeException */ public static function newFromConfig( array $config, TranslatableDescription $translatableDescription ): self { diff --git a/src/Services/PaymentUrlGenerator/PayPalURLGenerator.php b/src/Services/PaymentUrlGenerator/PayPalURLGenerator.php new file mode 100644 index 00000000..014b28e0 --- /dev/null +++ b/src/Services/PaymentUrlGenerator/PayPalURLGenerator.php @@ -0,0 +1,21 @@ +url; + } + +} diff --git a/src/Domain/PaymentUrlGenerator/Sofort/Request.php b/src/Services/PaymentUrlGenerator/Sofort/Request.php similarity index 95% rename from src/Domain/PaymentUrlGenerator/Sofort/Request.php rename to src/Services/PaymentUrlGenerator/Sofort/Request.php index aa614950..39826ad9 100644 --- a/src/Domain/PaymentUrlGenerator/Sofort/Request.php +++ b/src/Services/PaymentUrlGenerator/Sofort/Request.php @@ -2,7 +2,7 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort; use WMDE\Euro\Euro; diff --git a/src/Domain/PaymentUrlGenerator/Sofort/Response.php b/src/Services/PaymentUrlGenerator/Sofort/Response.php similarity index 87% rename from src/Domain/PaymentUrlGenerator/Sofort/Response.php rename to src/Services/PaymentUrlGenerator/Sofort/Response.php index 04ea03a4..b7c6599f 100644 --- a/src/Domain/PaymentUrlGenerator/Sofort/Response.php +++ b/src/Services/PaymentUrlGenerator/Sofort/Response.php @@ -2,7 +2,7 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort; class Response { diff --git a/src/Domain/PaymentUrlGenerator/Sofort/SofortClient.php b/src/Services/PaymentUrlGenerator/Sofort/SofortClient.php similarity index 68% rename from src/Domain/PaymentUrlGenerator/Sofort/SofortClient.php rename to src/Services/PaymentUrlGenerator/Sofort/SofortClient.php index 9777f5c7..ca9b242b 100644 --- a/src/Domain/PaymentUrlGenerator/Sofort/SofortClient.php +++ b/src/Services/PaymentUrlGenerator/Sofort/SofortClient.php @@ -2,7 +2,7 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort; /** * Custom facade around he Sofort client library diff --git a/src/Services/PaymentUrlGenerator/SofortURLGenerator.php b/src/Services/PaymentUrlGenerator/SofortURLGenerator.php new file mode 100644 index 00000000..b67a8ce5 --- /dev/null +++ b/src/Services/PaymentUrlGenerator/SofortURLGenerator.php @@ -0,0 +1,55 @@ +setAmount( $this->payment->getAmount() ); + $request->setCurrencyCode( self::CURRENCY ); + $request->setReasons( [ + $this->config->getTranslatableDescription()->getText( + $this->payment->getAmount(), + $this->payment->getInterval() + ), + $this->payment->getPaymentReferenceCode() + ] ); + $request->setSuccessUrl( + $this->authenticator->addAuthenticationTokensToApplicationUrl( $this->config->getReturnUrl() ) + ); + $request->setAbortUrl( $this->config->getCancelUrl() ); + $request->setNotificationUrl( + $this->authenticator->addAuthenticationTokensToApplicationUrl( $this->config->getNotificationUrl() ) + ); + $request->setLocale( $this->config->getLocale() ); + + try { + $response = $this->client->get( $request ); + } catch ( RuntimeException $exception ) { + throw new RuntimeException( 'Could not generate Sofort URL: ' . $exception->getMessage() ); + } + + return $response->getPaymentUrl(); + } +} diff --git a/src/Domain/PaymentUrlGenerator/SofortConfig.php b/src/Services/PaymentUrlGenerator/SofortURLGeneratorConfig.php similarity index 86% rename from src/Domain/PaymentUrlGenerator/SofortConfig.php rename to src/Services/PaymentUrlGenerator/SofortURLGeneratorConfig.php index a18ecd1f..9cacd40e 100644 --- a/src/Domain/PaymentUrlGenerator/SofortConfig.php +++ b/src/Services/PaymentUrlGenerator/SofortURLGeneratorConfig.php @@ -2,9 +2,9 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator; -class SofortConfig { +class SofortURLGeneratorConfig { public function __construct( private string $locale, diff --git a/src/Domain/PaymentUrlGenerator/TranslatableDescription.php b/src/Services/PaymentUrlGenerator/TranslatableDescription.php similarity index 83% rename from src/Domain/PaymentUrlGenerator/TranslatableDescription.php rename to src/Services/PaymentUrlGenerator/TranslatableDescription.php index 071d9988..ed96f261 100644 --- a/src/Domain/PaymentUrlGenerator/TranslatableDescription.php +++ b/src/Services/PaymentUrlGenerator/TranslatableDescription.php @@ -2,7 +2,7 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator; +namespace WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator; use WMDE\Euro\Euro; use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentInterval; diff --git a/src/Services/SofortLibClient.php b/src/Services/SofortLibClient.php index de215c83..52373bb1 100644 --- a/src/Services/SofortLibClient.php +++ b/src/Services/SofortLibClient.php @@ -6,9 +6,9 @@ use RuntimeException; use Sofort\SofortLib\Sofortueberweisung; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Request; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Response; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\SofortClient; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Request; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Response; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\SofortClient; /** * Facade in front of Sofortueberweisung, an API to generate URLs of Sofort's checkout process diff --git a/src/Services/TransactionIdFinder/DoctrineTransactionIdFinder.php b/src/Services/TransactionIdFinder/DoctrineTransactionIdFinder.php index 82e8dd20..90ddca25 100644 --- a/src/Services/TransactionIdFinder/DoctrineTransactionIdFinder.php +++ b/src/Services/TransactionIdFinder/DoctrineTransactionIdFinder.php @@ -4,10 +4,9 @@ namespace WMDE\Fundraising\PaymentContext\Services\TransactionIdFinder; use Doctrine\DBAL\Connection; -use WMDE\Fundraising\PaymentContext\DataAccess\ScalarTypeConverter; use WMDE\Fundraising\PaymentContext\Domain\Model\Payment; -use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentInterval; use WMDE\Fundraising\PaymentContext\Domain\Model\PayPalPayment; +use WMDE\Fundraising\PaymentContext\ScalarTypeConverter; use WMDE\Fundraising\PaymentContext\Services\TransactionIdFinder; class DoctrineTransactionIdFinder implements TransactionIdFinder { @@ -19,7 +18,7 @@ public function getAllTransactionIDs( Payment $payment ): array { return []; } // A small performance shortcut to avoid a database hit - if ( $payment->getInterval() === PaymentInterval::OneTime ) { + if ( !$payment->getInterval()->isRecurring() ) { return [ $payment->getTransactionId() => $payment->getId() ]; } $parent = $payment->getParentPayment(); diff --git a/src/Services/URLAuthenticator.php b/src/Services/URLAuthenticator.php new file mode 100644 index 00000000..c3ff74af --- /dev/null +++ b/src/Services/URLAuthenticator.php @@ -0,0 +1,26 @@ + + */ + public function getAuthenticationTokensForPaymentProviderUrl( string $urlGeneratorClass, array $requestedParameters ): array; +} diff --git a/src/Services/UrlGeneratorFactory.php b/src/Services/UrlGeneratorFactory.php new file mode 100644 index 00000000..3cc69f51 --- /dev/null +++ b/src/Services/UrlGeneratorFactory.php @@ -0,0 +1,11 @@ +paymentValidator->validatePaymentData( $request->amountInEuroCents, $request->interval, $request->paymentType, $request->getDomainSpecificPaymentValidator() ); + $validationResult = $this->paymentValidator->validatePaymentData( $request->amountInEuroCents, $request->interval, $request->paymentType, $request->domainSpecificPaymentValidator ); if ( !$validationResult->isSuccessful() ) { return new FailureResponse( $validationResult->getValidationErrors()[0]->getMessageIdentifier() ); } try { - $payment = $this->tryCreatePayment( $request ); + $payment = $this->tryCreatePayment( $request->getParameters() ); } catch ( PaymentCreationException $e ) { return new FailureResponse( $e->getMessage() ); } - $paymentProviderURLGenerator = $this->createPaymentProviderURLGenerator( $payment ); + $paymentProvider = $this->paymentProviderAdapterFactory->createProvider( $payment, $request->urlAuthenticator ); + + // payment providers may modify the payment or store payment-adjacent data + // (e.g. PayPal payment IDs) + $payment = $paymentProvider->fetchAndStoreAdditionalData( $payment, $request->domainSpecificContext ); $this->paymentRepository->storePayment( $payment ); - return new SuccessResponse( $payment->getId(), $paymentProviderURLGenerator, $payment->isCompleted() ); + + return new SuccessResponse( + $payment->getId(), + $this->generatePaymentCompletionUrl( $payment, $paymentProvider, $request ), + $payment->isCompleted() + ); } /** - * @param PaymentCreationRequest $request + * @param PaymentParameters $parameters * @return Payment * @throws PaymentCreationException */ - private function tryCreatePayment( PaymentCreationRequest $request ): Payment { - return match ( PaymentType::tryFrom( $request->paymentType ) ) { - PaymentType::CreditCard => $this->createCreditCardPayment( $request ), - PaymentType::Paypal => $this->createPayPalPayment( $request ), - PaymentType::Sofort => $this->createSofortPayment( $request ), - PaymentType::BankTransfer => $this->createBankTransferPayment( $request ), - PaymentType::DirectDebit => $this->createDirectDebitPayment( $request ), + private function tryCreatePayment( PaymentParameters $parameters ): Payment { + return match ( PaymentType::tryFrom( $parameters->paymentType ) ) { + PaymentType::CreditCard => $this->createCreditCardPayment( $parameters ), + PaymentType::Paypal => $this->createPayPalPayment( $parameters ), + PaymentType::Sofort => $this->createSofortPayment( $parameters ), + PaymentType::BankTransfer => $this->createBankTransferPayment( $parameters ), + PaymentType::DirectDebit => $this->createDirectDebitPayment( $parameters ), default => throw new \LogicException( sprintf( - 'Invalid payment type not caught by %s: %s', PaymentValidator::class, $request->paymentType + 'Invalid payment type not caught by %s: %s', PaymentValidator::class, $parameters->paymentType ) ) }; } /** - * @param PaymentCreationRequest $request + * @param PaymentParameters $parameters * @return CreditCardPayment */ - private function createCreditCardPayment( PaymentCreationRequest $request ): CreditCardPayment { + private function createCreditCardPayment( PaymentParameters $parameters ): CreditCardPayment { return new CreditCardPayment( $this->idGenerator->getNewId(), - Euro::newFromCents( $request->amountInEuroCents ), - PaymentInterval::from( $request->interval ) + Euro::newFromCents( $parameters->amountInEuroCents ), + PaymentInterval::from( $parameters->interval ) ); } /** - * @param PaymentCreationRequest $request + * @param PaymentParameters $parameters * @return PayPalPayment */ - private function createPayPalPayment( PaymentCreationRequest $request ): PayPalPayment { + private function createPayPalPayment( PaymentParameters $parameters ): PayPalPayment { return new PayPalPayment( $this->idGenerator->getNewId(), - Euro::newFromCents( $request->amountInEuroCents ), - PaymentInterval::from( $request->interval ) + Euro::newFromCents( $parameters->amountInEuroCents ), + PaymentInterval::from( $parameters->interval ) ); } /** - * @param PaymentCreationRequest $request + * @param PaymentParameters $parameters * @return SofortPayment * @throws PaymentCreationException */ - private function createSofortPayment( PaymentCreationRequest $request ): SofortPayment { - $paymentInterval = PaymentInterval::from( $request->interval ); - if ( $paymentInterval !== PaymentInterval::OneTime ) { + private function createSofortPayment( PaymentParameters $parameters ): SofortPayment { + $paymentInterval = PaymentInterval::from( $parameters->interval ); + if ( $paymentInterval->isRecurring() ) { throw new PaymentCreationException( "Sofort payment does not support recurring intervals (>0)." ); } return SofortPayment::create( $this->idGenerator->getNewId(), - Euro::newFromCents( $request->amountInEuroCents ), + Euro::newFromCents( $parameters->amountInEuroCents ), $paymentInterval, - $this->paymentReferenceCodeGenerator->newPaymentReference( $request->transferCodePrefix ) + $this->paymentReferenceCodeGenerator->newPaymentReference( $parameters->transferCodePrefix ) ); } /** - * @param PaymentCreationRequest $request + * @param PaymentParameters $parameters * @return BankTransferPayment */ - private function createBankTransferPayment( PaymentCreationRequest $request ): BankTransferPayment { + private function createBankTransferPayment( PaymentParameters $parameters ): BankTransferPayment { return BankTransferPayment::create( $this->idGenerator->getNewId(), - Euro::newFromCents( $request->amountInEuroCents ), - PaymentInterval::from( $request->interval ), - $this->paymentReferenceCodeGenerator->newPaymentReference( $request->transferCodePrefix ) + Euro::newFromCents( $parameters->amountInEuroCents ), + PaymentInterval::from( $parameters->interval ), + $this->paymentReferenceCodeGenerator->newPaymentReference( $parameters->transferCodePrefix ) ); } /** - * @param PaymentCreationRequest $request + * @param PaymentParameters $parameters * @return DirectDebitPayment * @throws PaymentCreationException */ - private function createDirectDebitPayment( PaymentCreationRequest $request ): DirectDebitPayment { - if ( $this->validateIbanUseCase->ibanIsValid( $request->iban ) instanceof BankDataFailureResponse ) { + private function createDirectDebitPayment( PaymentParameters $parameters ): DirectDebitPayment { + if ( $this->validateIbanUseCase->ibanIsValid( $parameters->iban ) instanceof BankDataFailureResponse ) { throw new PaymentCreationException( "An invalid IBAN was provided" ); } return DirectDebitPayment::create( $this->idGenerator->getNewId(), - Euro::newFromCents( $request->amountInEuroCents ), - PaymentInterval::from( $request->interval ), - new Iban( $request->iban ), - $request->bic + Euro::newFromCents( $parameters->amountInEuroCents ), + PaymentInterval::from( $parameters->interval ), + new Iban( $parameters->iban ), + $parameters->bic ); } - private function createPaymentProviderURLGenerator( Payment $payment ): PaymentProviderURLGenerator { - return $this->paymentURLFactory->createURLGenerator( $payment ); + private function generatePaymentCompletionUrl( + Payment $payment, + PaymentProviderAdapter $paymentProvider, + PaymentCreationRequest $request, + ): string { + $domainSpecificContext = $request->domainSpecificContext; + $paymentProviderURLGenerator = $this->paymentURLFactory->createURLGenerator( $payment, $request->urlAuthenticator ); + $paymentProviderURLGenerator = $paymentProvider->modifyPaymentUrlGenerator( $paymentProviderURLGenerator, $domainSpecificContext ); + return $paymentProviderURLGenerator->generateURL( $domainSpecificContext->getRequestContextForUrlGenerator() ); } } diff --git a/src/UseCases/CreatePayment/DefaultPaymentProviderAdapter.php b/src/UseCases/CreatePayment/DefaultPaymentProviderAdapter.php new file mode 100644 index 00000000..db5a4c54 --- /dev/null +++ b/src/UseCases/CreatePayment/DefaultPaymentProviderAdapter.php @@ -0,0 +1,27 @@ +domainSpecificPaymentValidator; + public static function newFromParameters( + PaymentParameters $parameters, + DomainSpecificPaymentValidator $domainSpecificPaymentValidator, + DomainSpecificContext $domainSpecificContext, + URLAuthenticator $urlAuthenticator + ): self { + return new self( + $parameters->amountInEuroCents, + $parameters->interval, + $parameters->paymentType, + $domainSpecificPaymentValidator, + $domainSpecificContext, + $urlAuthenticator, + $parameters->iban, + $parameters->bic, + $parameters->transferCodePrefix + ); } - public function setDomainSpecificPaymentValidator( DomainSpecificPaymentValidator $domainSpecificPaymentValidator ): void { - $this->domainSpecificPaymentValidator = $domainSpecificPaymentValidator; + public function getParameters(): PaymentParameters { + return new PaymentParameters( + $this->amountInEuroCents, + $this->interval, + $this->paymentType, + $this->iban, + $this->bic, + $this->transferCodePrefix + ); } public function jsonSerialize(): mixed { $objectVars = get_object_vars( $this ); $objectVars['domainSpecificPaymentValidator'] = get_class( $this->domainSpecificPaymentValidator ); + unset( $objectVars['urlAuthenticator'] ); return (object)$objectVars; } @@ -37,11 +63,10 @@ public function __toString(): string { $encodedResult = json_encode( $this->jsonSerialize() ); if ( $encodedResult === false ) { return sprintf( "JSON encode error in %s: %s", - __METHOD__, - json_last_error_msg() + __METHOD__, + json_last_error_msg() ); } return $encodedResult; } - } diff --git a/src/UseCases/CreatePayment/PaymentParameters.php b/src/UseCases/CreatePayment/PaymentParameters.php new file mode 100644 index 00000000..cc179f9b --- /dev/null +++ b/src/UseCases/CreatePayment/PaymentParameters.php @@ -0,0 +1,21 @@ +productAlreadyExists( $request->productId ); + } catch ( PayPalAPIException $e ) { + return new ErrorResult( $e->getMessage() ); + } + + if ( $resultProduct === null ) { + $productAlreadyExisted = false; + try { + $resultProduct = $this->api->createProduct( new Product( $request->productId, $request->productName ) ); + } catch ( \Exception $e ) { + return new ErrorResult( $e->getMessage() ); + } + } else { + $productAlreadyExisted = true; + } + + // get plan, if it exists + try { + $resultSubscriptionPlan = $this->getPlanForProductAndInterval( $request->productId, $request->interval ); + } catch ( PayPalAPIException $e ) { + return new ErrorResult( $e->getMessage() ); + } + + // Create plan if it doesn't exist + if ( $resultSubscriptionPlan === null ) { + $planAlreadyExistsForThisProduct = false; + try { + $resultSubscriptionPlan = $this->api->createSubscriptionPlanForProduct( + new SubscriptionPlan( + $request->planName, + $request->productId, + $request->interval + ) + ); + } catch ( \Exception $e ) { + return new ErrorResult( $e->getMessage() ); + } + } else { + $planAlreadyExistsForThisProduct = true; + } + + return new SuccessResult( $resultProduct, $productAlreadyExisted, $resultSubscriptionPlan, $planAlreadyExistsForThisProduct ); + } + + private function productAlreadyExists( string $id ): ?Product { + foreach ( $this->api->listProducts() as $retrievedProduct ) { + if ( $retrievedProduct->id === $id ) { + return $retrievedProduct; + } + } + return null; + } + + private function getPlanForProductAndInterval( string $productId, PaymentInterval $interval ): ?SubscriptionPlan { + foreach ( $this->api->listSubscriptionPlansForProduct( $productId ) as $plan ) { + if ( $plan->monthlyInterval === $interval ) { + return $plan; + } + } + return null; + } + +} diff --git a/src/UseCases/CreateSubscriptionPlansForProduct/CreateSubscriptionPlanRequest.php b/src/UseCases/CreateSubscriptionPlansForProduct/CreateSubscriptionPlanRequest.php new file mode 100644 index 00000000..59b219fa --- /dev/null +++ b/src/UseCases/CreateSubscriptionPlansForProduct/CreateSubscriptionPlanRequest.php @@ -0,0 +1,24 @@ +interval->isRecurring() ) { + throw new \UnexpectedValueException( "Interval must be recurring" ); + } + } +} diff --git a/src/UseCases/CreateSubscriptionPlansForProduct/ErrorResult.php b/src/UseCases/CreateSubscriptionPlansForProduct/ErrorResult.php new file mode 100644 index 00000000..3f85f411 --- /dev/null +++ b/src/UseCases/CreateSubscriptionPlansForProduct/ErrorResult.php @@ -0,0 +1,11 @@ + self::BASE_URL, + 'locale' => self::LOCALE, + 'account-address' => self::ACCOUNT_ADDRESS, + 'notify-url' => self::NOTIFY_URL, + 'return-url' => self::RETURN_URL, + 'cancel-url' => self::CANCEL_URL + ], + new class( self::ITEM_NAME ) implements TranslatableDescription { + public function __construct( private readonly string $itemName ) { + } + + public function getText( Euro $paymentAmount, PaymentInterval $paymentInterval ): string { + return $this->itemName; + } + } + ); + } +} diff --git a/tests/Fixtures/FakePayPalAPIForPayments.php b/tests/Fixtures/FakePayPalAPIForPayments.php new file mode 100644 index 00000000..43adb252 --- /dev/null +++ b/tests/Fixtures/FakePayPalAPIForPayments.php @@ -0,0 +1,104 @@ +subscriptions ); + if ( $subscription === false ) { + throw new \OutOfBoundsException( 'Your test setup did not add enough subscriptions to the fake API implementation' ); + } + $this->subscriptionParameters[] = $subscriptionParameters; + next( $this->subscriptions ); + return $subscription; + } + + public function createOrder( OrderParameters $orderParameters ): Order { + $order = current( $this->orders ); + if ( $order === false ) { + throw new \OutOfBoundsException( 'Your test setup did not add enough orders to the fake API implementation' ); + } + $this->orderParameters[] = $orderParameters; + next( $this->orders ); + return $order; + } + + /** + * @return SubscriptionParameters[] + */ + public function getSubscriptionParameters(): array { + return $this->subscriptionParameters; + } + + /** + * @return OrderParameters[] + */ + public function getOrderParameters(): array { + return $this->orderParameters; + } + +} diff --git a/tests/Fixtures/FakePayPalAPIForSetup.php b/tests/Fixtures/FakePayPalAPIForSetup.php new file mode 100644 index 00000000..8f59db24 --- /dev/null +++ b/tests/Fixtures/FakePayPalAPIForSetup.php @@ -0,0 +1,104 @@ +> + */ + private array $subscriptionPlans = []; + + /** + * @param Product[] $products + * @param SubscriptionPlan[] $subscriptionPlans + */ + public function __construct( + array $products = [], + array $subscriptionPlans = [] + ) { + foreach ( $products as $product ) { + $this->createProduct( $product ); + } + foreach ( $subscriptionPlans as $subscriptionPlan ) { + $this->createSubscriptionPlanForProduct( $subscriptionPlan ); + } + } + + /** + * @return Product[] + */ + public function listProducts(): array { + return $this->products; + } + + public function createProduct( Product $product ): Product { + $this->products[ $product->id ] = $product; + return $product; + } + + public function hasProduct( Product $product ): bool { + if ( empty( $this->products[ $product->id ] ) ) { + return false; + } else { + // compare by value, not by reference + return $this->products[ $product->id ] == $product; + } + } + + public function hasSubscriptionPlan( SubscriptionPlan $subscriptionPlan ): bool { + if ( empty( $this->subscriptionPlans[ $subscriptionPlan->productId ][$subscriptionPlan->monthlyInterval->value] ) ) { + return false; + } + $subscriptionPlanFromStorage = $this->subscriptionPlans[ $subscriptionPlan->productId ][$subscriptionPlan->monthlyInterval->value]; + return $subscriptionPlanFromStorage->productId === $subscriptionPlan->productId && + $subscriptionPlanFromStorage->monthlyInterval === $subscriptionPlan->monthlyInterval; + } + + /** + * @return SubscriptionPlan[] + */ + public function listSubscriptionPlansForProduct( string $productId ): array { + return $this->subscriptionPlans[$productId] ?? []; + } + + public function createSubscriptionPlanForProduct( SubscriptionPlan $subscriptionPlan ): SubscriptionPlan { + $storedSubscriptionPlan = new SubscriptionPlan( $subscriptionPlan->name, $subscriptionPlan->productId, $subscriptionPlan->monthlyInterval, self::GENERATED_ID ); + if ( empty( $this->subscriptionPlans[$subscriptionPlan->productId] ) ) { + $this->subscriptionPlans[$subscriptionPlan->productId] = [ $subscriptionPlan->monthlyInterval->value => $storedSubscriptionPlan ]; + } else { + $this->subscriptionPlans[ $subscriptionPlan->productId ][$subscriptionPlan->monthlyInterval->value] = $storedSubscriptionPlan; + } + return $storedSubscriptionPlan; + } + + public function createSubscription( SubscriptionParameters $subscriptionParameters ): Subscription { + throw new \LogicException( 'Not implemented yet, your tests should not use it' ); + } + + public function createOrder( OrderParameters $orderParameters ): Order { + throw new \LogicException( 'Not implemented yet, your tests should not use it' ); + } + +} diff --git a/tests/Fixtures/FakePaymentReferenceCode.php b/tests/Fixtures/FakePaymentReferenceCode.php new file mode 100644 index 00000000..e9977e21 --- /dev/null +++ b/tests/Fixtures/FakePaymentReferenceCode.php @@ -0,0 +1,13 @@ +buildUrl( $urlParts ); + } + + /** + * @param array $urlParts + * @return string + */ + private function buildUrl( array $urlParts ): string { + $scheme = isset( $urlParts['scheme'] ) ? $urlParts['scheme'] . '://' : ''; + + $host = $urlParts['host'] ?? ''; + + $path = $urlParts['path'] ?? ''; + + $query = isset( $urlParts['query'] ) ? '?' . $urlParts['query'] : ''; + + $fragment = isset( $urlParts['fragment'] ) ? '#' . $urlParts['fragment'] : ''; + + return "$scheme$host$path$query$fragment"; + } + + public function getAuthenticationTokensForPaymentProviderUrl( string $urlGeneratorClass, array $requestedParameters ): array { + $resultParameters = []; + foreach ( $requestedParameters as $idx => $parameter ) { + $resultParameters[$parameter] = 'p-test-token-' . $idx; + } + return $resultParameters; + } +} diff --git a/tests/Fixtures/PaymentCompletionURLGeneratorStub.php b/tests/Fixtures/PaymentCompletionURLGeneratorStub.php new file mode 100644 index 00000000..1b12521d --- /dev/null +++ b/tests/Fixtures/PaymentCompletionURLGeneratorStub.php @@ -0,0 +1,15 @@ +storePayment( $payment ); } + public function testStorePayPalIdentifierForRecurringPayment(): void { + $repo = new DoctrinePaymentRepository( $this->entityManager ); + $payment = new PayPalPayment( 5, Euro::newFromInt( 8 ), PaymentInterval::Yearly ); + $repo->storePayPalIdentifier( new PayPalSubscription( $payment, 'SID-1' ) ); + + $identifier = $this->fetchRawPayPalIdentifier( 5 ); + $this->assertSame( 5, $identifier['payment_id'] ); + $this->assertSame( 'SID-1', $identifier['subscription_id'] ); + $this->assertSame( 'S', $identifier['identifier_type'] ); + } + + public function testStorePayPalIdentifierForOneTimePayment(): void { + $repo = new DoctrinePaymentRepository( $this->entityManager ); + $payment = new PayPalPayment( 9, Euro::newFromInt( 87 ), PaymentInterval::OneTime ); + $repo->storePayPalIdentifier( new PayPalOrder( $payment, 'O-1234', 'TXN-9' ) ); + + $identifier = $this->fetchRawPayPalIdentifier( 9 ); + $this->assertSame( 9, $identifier['payment_id'] ); + $this->assertSame( 'O-1234', $identifier['order_id'] ); + $this->assertSame( 'TXN-9', $identifier['transaction_id'] ); + $this->assertSame( 'O', $identifier['identifier_type'] ); + } + /** * @return array * @throws \Doctrine\DBAL\Exception @@ -610,6 +636,23 @@ private function fetchRawPaymentReferenceCode(): array { return $data; } + /** + * @return array + * @throws \Doctrine\DBAL\Exception + */ + private function fetchRawPayPalIdentifier( int $paymentId ): array { + $data = $this->connection->createQueryBuilder() + ->select( 'p.subscription_id', 'p.order_id', 'p.transaction_id', 'p.payment_id', 'p.identifier_type' ) + ->from( 'payment_paypal_identifier', 'p' ) + ->where( 'p.payment_id = :payment_id' ) + ->setParameter( 'payment_id', $paymentId, ParameterType::INTEGER ) + ->fetchAssociative(); + if ( $data === false ) { + throw new AssertionFailedError( "Expected Paypal ID was not found!" ); + } + return $data; + } + private function makeIdGeneratorForFollowupPayments(): PaymentIdRepository { $idGeneratorStub = $this->createStub( PaymentIdRepository::class ); $idGeneratorStub->method( 'getNewId' )->willReturn( self::FOLLOWUP_PAYMENT_ID ); diff --git a/tests/TestPaymentContextFactory.php b/tests/TestPaymentContextFactory.php index 736fd120..aa4bfd02 100644 --- a/tests/TestPaymentContextFactory.php +++ b/tests/TestPaymentContextFactory.php @@ -40,7 +40,7 @@ public function getEntityManager(): EntityManager { } public function newEntityManager(): EntityManager { - return EntityManager::create( $this->newConnection(), $this->doctrineConfig ); + return new EntityManager( $this->newConnection(), $this->doctrineConfig ); } public function newSchemaCreator(): SchemaCreator { diff --git a/tests/Unit/DataAccess/ScalarTypeConverterTest.php b/tests/Unit/DataAccess/ScalarTypeConverterTest.php index 981b6d7b..a75e3ab4 100644 --- a/tests/Unit/DataAccess/ScalarTypeConverterTest.php +++ b/tests/Unit/DataAccess/ScalarTypeConverterTest.php @@ -4,10 +4,10 @@ namespace WMDE\Fundraising\PaymentContext\Tests\Unit\DataAccess; use PHPUnit\Framework\TestCase; -use WMDE\Fundraising\PaymentContext\DataAccess\ScalarTypeConverter; +use WMDE\Fundraising\PaymentContext\ScalarTypeConverter; /** - * @covers \WMDE\Fundraising\PaymentContext\DataAccess\ScalarTypeConverter + * @covers \WMDE\Fundraising\PaymentContext\ScalarTypeConverter */ class ScalarTypeConverterTest extends TestCase { /** diff --git a/tests/Unit/Domain/Model/PayPalOrderTest.php b/tests/Unit/Domain/Model/PayPalOrderTest.php new file mode 100644 index 00000000..f26eb7e0 --- /dev/null +++ b/tests/Unit/Domain/Model/PayPalOrderTest.php @@ -0,0 +1,78 @@ +expectException( \DomainException::class ); + + new PayPalOrder( new PayPalPayment( 1, Euro::newFromCents( 1000 ), PaymentInterval::Monthly ), self::ORDER_ID ); + } + + /** + * @dataProvider provideEmptyIDs + */ + public function testConstructorEnforcesNonEmptyOrderId( string $orderId ): void { + $this->expectException( \DomainException::class ); + + new PayPalOrder( $this->givenOneTimePayment(), $orderId ); + } + + /** + * @return iterable + */ + public static function provideEmptyIDs(): iterable { + yield 'empty string' => [ '' ]; + yield 'string consisting of spaces' => [ ' ' ]; + yield 'string consisting of whitespace characters' => [ "\t\r\n " ]; + } + + public function testConstructorSetsProperties(): void { + $payment = $this->givenOneTimePayment(); + $order = new PayPalOrder( $payment, self::ORDER_ID, self::TRANSACTION_ID ); + + $this->assertSame( $payment, $order->getPayment() ); + $this->assertSame( self::ORDER_ID, $order->getOrderId() ); + $this->assertSame( self::TRANSACTION_ID, $order->getTransactionId() ); + } + + /** + * @dataProvider provideEmptyIDs + */ + public function testSettingEmptyTransactionIdThrowException( string $transactionId ): void { + $order = new PayPalOrder( $this->givenOneTimePayment(), self::ORDER_ID ); + + $this->expectException( \DomainException::class ); + $this->expectExceptionMessage( 'Transaction ID must not be empty when setting it explicitly' ); + $order->setTransactionId( $transactionId ); + } + + public function testWhenTransactionIdHasBeenSetItMustNotChange(): void { + $order = new PayPalOrder( $this->givenOneTimePayment(), self::ORDER_ID ); + $order->setTransactionId( self::TRANSACTION_ID ); + + $this->expectException( \DomainException::class ); + $this->expectExceptionMessage( 'Transaction ID must not be changed' ); + $order->setTransactionId( self::ANOTHER_TRANSACTION_ID ); + } + + private function givenOneTimePayment(): PayPalPayment { + return new PayPalPayment( 1, Euro::newFromCents( 5000 ), PaymentInterval::OneTime ); + } +} diff --git a/tests/Unit/Domain/Model/PayPalSubscriptionTest.php b/tests/Unit/Domain/Model/PayPalSubscriptionTest.php new file mode 100644 index 00000000..80d9b7dd --- /dev/null +++ b/tests/Unit/Domain/Model/PayPalSubscriptionTest.php @@ -0,0 +1,54 @@ +expectException( \DomainException::class ); + + new PayPalSubscription( new PayPalPayment( 1, Euro::newFromCents( 1000 ), PaymentInterval::OneTime ), self::SUBSCRIPTION_ID ); + } + + /** + * @dataProvider provideEmptySubscriptionIDs + */ + public function testConstructorEnforcesNonEmptySubscriptionId( string $subscriptionId ): void { + $this->expectException( \DomainException::class ); + + new PayPalSubscription( $this->givenMonthlyPayment(), $subscriptionId ); + } + + /** + * @return iterable + */ + public static function provideEmptySubscriptionIDs(): iterable { + yield 'empty string' => [ '' ]; + yield 'string consisting of spaces' => [ ' ' ]; + yield 'string consisting of whitespace characters' => [ "\t\r\n " ]; + } + + public function testConstructorSetsProperties(): void { + $payment = $this->givenMonthlyPayment(); + $subscription = new PayPalSubscription( $payment, self::SUBSCRIPTION_ID ); + + $this->assertSame( $payment, $subscription->getPayment() ); + $this->assertSame( self::SUBSCRIPTION_ID, $subscription->getSubscriptionId() ); + } + + private function givenMonthlyPayment(): PayPalPayment { + return new PayPalPayment( 1, Euro::newFromCents( 1000 ), PaymentInterval::Monthly ); + } +} diff --git a/tests/Unit/Domain/Model/PaymentIntervalTest.php b/tests/Unit/Domain/Model/PaymentIntervalTest.php new file mode 100644 index 00000000..d1f02850 --- /dev/null +++ b/tests/Unit/Domain/Model/PaymentIntervalTest.php @@ -0,0 +1,47 @@ +assertFalse( $interval->isRecurring() ); + } + + /** + * @dataProvider recurringIntervals + */ + public function testRecurring( PaymentInterval $interval ): void { + $this->assertTrue( $interval->isRecurring() ); + } + + /** + * @return iterable + */ + public static function recurringIntervals(): iterable { + yield [ PaymentInterval::Monthly ]; + yield [ PaymentInterval::Quarterly ]; + yield [ PaymentInterval::HalfYearly ]; + yield [ PaymentInterval::Yearly ]; + } + + public function testCreatesFromString(): void { + $this->assertSame( PaymentInterval::OneTime, PaymentInterval::fromString( "OneTime" ) ); + $this->assertSame( PaymentInterval::Monthly, PaymentInterval::fromString( "Monthly" ) ); + $this->assertSame( PaymentInterval::Quarterly, PaymentInterval::fromString( "Quarterly" ) ); + $this->assertSame( PaymentInterval::HalfYearly, PaymentInterval::fromString( "HalfYearly" ) ); + $this->assertSame( PaymentInterval::Yearly, PaymentInterval::fromString( "Yearly" ) ); + } + + public function testGivenNonExistingIntervalFromStringThrowsException(): void { + $this->expectException( \OutOfBoundsException::class ); + PaymentInterval::fromString( "every blue moon" ); + } +} diff --git a/tests/Unit/Domain/PaymentUrlGenerator/NullGeneratorTest.php b/tests/Unit/Domain/PaymentUrlGenerator/NullGeneratorTest.php deleted file mode 100644 index e2daa98b..00000000 --- a/tests/Unit/Domain/PaymentUrlGenerator/NullGeneratorTest.php +++ /dev/null @@ -1,24 +0,0 @@ -createMock( RequestContext::class ); - - $nullGenerator = new NullGenerator(); - - self::assertSame( '', $nullGenerator->generateURL( $contextMock ) ); - } - -} diff --git a/tests/Unit/Domain/PaymentUrlGenerator/PayPalConfigTest.php b/tests/Unit/Domain/PaymentUrlGenerator/PayPalConfigTest.php deleted file mode 100644 index 57cb4792..00000000 --- a/tests/Unit/Domain/PaymentUrlGenerator/PayPalConfigTest.php +++ /dev/null @@ -1,53 +0,0 @@ -expectException( \RuntimeException::class ); - $this->newIncompletePayPalUrlConfig(); - } - - private function newIncompletePayPalUrlConfig(): PayPalConfig { - return PayPalConfig::newFromConfig( - [ - PayPalConfig::CONFIG_KEY_BASE_URL => 'http://that.paymentprovider.com/?', - PayPalConfig::CONFIG_KEY_LOCALE => 'de_DE', - PayPalConfig::CONFIG_KEY_ACCOUNT_ADDRESS => 'some@email-adress.com', - PayPalConfig::CONFIG_KEY_NOTIFY_URL => 'http://my.donation.app/handler/paypal/', - PayPalConfig::CONFIG_KEY_RETURN_URL => 'http://my.donation.app/donation/confirm/', - PayPalConfig::CONFIG_KEY_CANCEL_URL => '', - ], - $this->createMock( TranslatableDescription::class ) - ); - } - - public function testGivenValidPayPalUrlConfig_payPalConfigIsReturned(): void { - $this->assertInstanceOf( PayPalConfig::class, $this->newPayPalUrlConfig() ); - } - - private function newPayPalUrlConfig(): PayPalConfig { - return PayPalConfig::newFromConfig( - [ - PayPalConfig::CONFIG_KEY_BASE_URL => 'http://that.paymentprovider.com/?', - PayPalConfig::CONFIG_KEY_LOCALE => 'de_DE', - PayPalConfig::CONFIG_KEY_ACCOUNT_ADDRESS => 'some@email-adress.com', - PayPalConfig::CONFIG_KEY_NOTIFY_URL => 'http://my.donation.app/handler/paypal/', - PayPalConfig::CONFIG_KEY_RETURN_URL => 'http://my.donation.app/donation/confirm/', - PayPalConfig::CONFIG_KEY_CANCEL_URL => 'http://my.donation.app/donation/cancel/', - ], - $this->createMock( TranslatableDescription::class ) - ); - } - -} diff --git a/tests/Unit/Domain/PaymentUrlGenerator/PaymentURLFactoryTest.php b/tests/Unit/Domain/PaymentUrlGenerator/PaymentURLFactoryTest.php deleted file mode 100644 index 8f980bc1..00000000 --- a/tests/Unit/Domain/PaymentUrlGenerator/PaymentURLFactoryTest.php +++ /dev/null @@ -1,84 +0,0 @@ -createTestURLFactory(); - $payment = SofortPayment::create( - 1, - Euro::newFromCents( 1000 ), - PaymentInterval::OneTime, - new PaymentReferenceCode( 'XW', 'DARE99', 'X' ) - ); - - $actualGenerator = $urlFactory->createURLGenerator( $payment ); - - self::assertInstanceOf( Sofort::class, $actualGenerator ); - } - - public function testPaymentURLFactoryReturnsNewCreditCardURLGenerator(): void { - $urlFactory = $this->createTestURLFactory(); - $payment = new CreditCardPayment( 1, Euro::newFromInt( 99 ), PaymentInterval::Quarterly ); - - $actualGenerator = $urlFactory->createURLGenerator( $payment ); - - self::assertInstanceOf( CreditCard::class, $actualGenerator ); - } - - public function testPaymentURLFactoryReturnsNewPayPalURLGenerator(): void { - $urlFactory = $this->createTestURLFactory(); - $payment = new PayPalPayment( 1, Euro::newFromInt( 99 ), PaymentInterval::Quarterly ); - - $actualGenerator = $urlFactory->createURLGenerator( $payment ); - - self::assertInstanceOf( PayPal::class, $actualGenerator ); - } - - public function testPaymentURLFactoryReturnsNewNullURLGenerator(): void { - $urlFactory = $this->createTestURLFactory(); - - $payment = $this->createMock( Payment::class ); - - $actualGenerator = $urlFactory->createURLGenerator( $payment ); - - self::assertInstanceOf( NullGenerator::class, $actualGenerator ); - } - - private function createTestURLFactory(): PaymentURLFactory { - $creditCardConfig = $this->createStub( CreditCardConfig::class ); - $payPalConfig = $this->createStub( PayPalConfig::class ); - $sofortConfig = $this->createStub( SofortConfig::class ); - $sofortClient = $this->createStub( SofortClient::class ); - return new PaymentURLFactory( - $creditCardConfig, - $payPalConfig, - $sofortConfig, - $sofortClient - ); - } -} diff --git a/tests/Unit/Services/PayPal/GuzzlePaypalAPITest.php b/tests/Unit/Services/PayPal/GuzzlePaypalAPITest.php new file mode 100644 index 00000000..6037a624 --- /dev/null +++ b/tests/Unit/Services/PayPal/GuzzlePaypalAPITest.php @@ -0,0 +1,699 @@ +> + */ + private array $guzzleHistory; + + protected function setUp(): void { + $this->guzzleHistory = []; + } + + public function testListProductsSendsCredentials(): void { + $client = $this->givenClientWithResponses( + $this->createEmptyProductResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $guzzlePaypalApi->listProducts(); + + $this->assertCount( 1, $this->guzzleHistory, 'We expect a list request' ); + /** @var Request $listRequest */ + $listRequest = $this->guzzleHistory[ 0 ][ 'request' ]; + $this->assertSame( + self::BASIC_AUTH_HEADER, + $listRequest->getHeaderLine( 'authorization' ) + ); + } + + public function testWhenApiReturnsMalformedJsonThrowException(): void { + $logger = new LoggerSpy(); + $responseBody = '{"sss_reserved": "0"'; + $malformedJsonResponse = new Response( 200, [], $responseBody ); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( $malformedJsonResponse ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->listProducts(); + $this->fail( 'listProducts should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertJSONException( $e, $logger, $responseBody ); + } + } + + public function testWhenApiReturnsJSONWithUnexpectedKeysLogServerResponseAndThrowException(): void { + $logger = new LoggerSpy(); + $responseBody = '{"error": "access denied" }'; + $responseWithoutAuthToken = new Response( 200, [], $responseBody ); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( $responseWithoutAuthToken ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->listProducts(); + $this->fail( 'listProducts should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertStringContainsString( "Listing products failed", $e->getMessage() ); + $firstCall = $logger->getFirstLogCall(); + $this->assertNotNull( $firstCall ); + $this->assertStringContainsString( "Listing products failed", $firstCall->getMessage() ); + $this->assertArrayHasKey( 'serverResponse', $firstCall->getContext() ); + $this->assertSame( $responseBody, $firstCall->getContext()['serverResponse'] ); + } + } + + public function testListProductsReturnsListOfProducts(): void { + $client = $this->givenClientWithResponses( + $this->createProductResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $actualProducts = $guzzlePaypalApi->listProducts(); + + $this->assertEquals( + [ + new Product( 'ID-1', 'WMDE_Donation', 'Description' ), + new Product( 'ID-2', 'WMDE_Membership', null ) + ], + $actualProducts + ); + /** @var Request $createRequest */ + $createRequest = $this->guzzleHistory[0]['request']; + $this->assertSame( 'GET', $createRequest->getMethod() ); + } + + public function testListProductsReturnsNoProductsWhenServerResponseContainsNoProducts(): void { + $client = $this->givenClientWithResponses( + $this->createEmptyProductResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $actualProducts = $guzzlePaypalApi->listProducts(); + + $this->assertEquals( + [], + $actualProducts + ); + } + + /** + * we only have 2 products and don't want to implement paging + * @return void + */ + public function testWhenServerIndicatesMultiplePagesOfProductsExceptionIsThrown(): void { + $logger = new LoggerSpy(); + $responseBody = <<givenClientWithResponses( $response ); + + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', $logger ); + + try { + $guzzlePaypalApi->listProducts(); + $this->fail( 'listProducts should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertStringContainsString( "Paging is not supported because we don't have that many products", $e->getMessage() ); + $firstCall = $logger->getFirstLogCall(); + $this->assertNotNull( $firstCall ); + $this->assertStringContainsString( "Paging is not supported because we don't have that many products", $firstCall->getMessage() ); + $this->assertArrayHasKey( 'serverResponse', $firstCall->getContext() ); + $this->assertSame( $responseBody, $firstCall->getContext()['serverResponse'] ); + } + } + + public function testCreateProductSendsProductData(): void { + $responseBody = <<givenClientWithResponses( $response ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + $product = new Product( "someSpecificID", 'WMDE_FUNNYDonation', 'WMDE_FUNNYDonationDescription' ); + + $guzzlePaypalApi->createProduct( $product ); + + $this->assertCount( 1, $this->guzzleHistory, 'We expect a create request' ); + $expectedRequestBody = <<guzzleHistory[ 0 ][ 'request' ]; + + $this->assertSame( 'POST', $createRequest->getMethod() ); + $this->assertSame( self::BASIC_AUTH_HEADER, $createRequest->getHeaderLine( 'authorization' ) ); + $this->assertSame( + json_encode( json_decode( $expectedRequestBody ) ), + $createRequest->getBody()->getContents() + ); + $this->assertSame( 'application/json', $createRequest->getHeaderLine( 'Content-Type' ) ); + $this->assertSame( 'application/json', $createRequest->getHeaderLine( 'Accept' ) ); + } + + public function testNewProductIsCreatedFromServerData(): void { + // The server response here has different values on purpose to make sure that all values come from the server + // In reality, the PayPal server should *never* change our values, only generate IDs if they are missing + $responseBody = <<givenClientWithResponses( $response ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + $product = new Product( 'FD1', 'WMDE_FUNNYDonation', ); + + $createdProduct = $guzzlePaypalApi->createProduct( $product ); + + $this->assertNotSame( $product, $createdProduct, 'method should create a new product from server data' ); + $this->assertSame( 'ServerId', $createdProduct->id ); + $this->assertSame( 'ServerDonation', $createdProduct->name ); + $this->assertSame( 'ServerDescription', $createdProduct->description ); + } + + public function testCreateProductFailsWhenServerResponseHasMalformedJson(): void { + $logger = new LoggerSpy(); + $responseBody = '{"sss_reserved": "0"'; + $malformedJsonResponse = new Response( 200, [], $responseBody ); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( $malformedJsonResponse ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->createProduct( new Product( 'D1', 'Dummy', ) ); + $this->fail( 'createProduct should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertJSONException( $e, $logger, $responseBody ); + } + } + + public function testCreateProductFailsWhenServerResponseDoesNotContainProductData(): void { + $logger = new LoggerSpy(); + $responseBody = '{"error": "access denied" }'; + $jsonResponseWithErrors = new Response( 200, [], $responseBody ); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( $jsonResponseWithErrors ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->createProduct( new Product( 'D1', 'Dummy', ) ); + $this->fail( 'createProduct should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertStringContainsString( "Server did not send product data back", $e->getMessage() ); + $firstCall = $logger->getFirstLogCall(); + $this->assertNotNull( $firstCall ); + $this->assertStringContainsString( "Server did not send product data back", $firstCall->getMessage() ); + $this->assertArrayHasKey( 'serverResponse', $firstCall->getContext() ); + $this->assertSame( $responseBody, $firstCall->getContext()['serverResponse'] ); + } + } + + public function testListSubscriptionPlansSendsCredentials(): void { + $client = $this->givenClientWithResponses( + $this->createEmptyPlanResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $guzzlePaypalApi->listSubscriptionPlansForProduct( 'donation' ); + + $this->assertCount( 1, $this->guzzleHistory, 'We expect a list request' ); + /** @var Request $listRequest */ + $listRequest = $this->guzzleHistory[ 0 ][ 'request' ]; + $this->assertSame( + self::BASIC_AUTH_HEADER, + $listRequest->getHeaderLine( 'authorization' ) + ); + } + + public function testListSubscriptionPlansQueriesOnlyRequestedProducts(): void { + $client = $this->givenClientWithResponses( + $this->createEmptyPlanResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $guzzlePaypalApi->listSubscriptionPlansForProduct( 'donation' ); + + $this->assertCount( 1, $this->guzzleHistory, 'We expect a list request' ); + /** @var Request $listRequest */ + $listRequest = $this->guzzleHistory[ 0 ][ 'request' ]; + $this->assertSame( + 'product_id=donation', + $listRequest->getUri()->getQuery() + ); + } + + public function testListSubscriptionPlansReturnsSubscriptions(): void { + $client = $this->givenClientWithResponses( + $this->createSubscriptionsResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $plans = $guzzlePaypalApi->listSubscriptionPlansForProduct( 'donation' ); + + $this->assertCount( 2, $plans ); + $this->assertSame( 'P-0HVWVNKK2LCV2VN57N79TLENELT78EKL', $plans[0]->id ); + $this->assertEquals( 'monthly donation', $plans[0]->name ); + } + + public function testListSubscriptionPlansThrowsErrorOnMalformedJSON(): void { + $responseBody = 'br0ken'; + $client = $this->givenClientWithResponses( + new Response( 200, [], $responseBody ) + ); + $loggerSpy = new LoggerSpy(); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', $loggerSpy ); + + try { + $guzzlePaypalApi->listSubscriptionPlansForProduct( 'donation' ); + $this->fail( 'It should throw an Exception.' ); + } catch ( PayPalAPIException $e ) { + $this->assertJSONException( $e, $loggerSpy, $responseBody ); + } + } + + public function testListSubscriptionPlansThrowsErrorOnMissingPlansProperty(): void { + $client = $this->givenClientWithResponses( + $this->createUndefinedPlansPropertyResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $this->expectExceptionMessage( 'Malformed JSON' ); + $guzzlePaypalApi->listSubscriptionPlansForProduct( 'donation' ); + } + + public function testListSubscriptionPlansThrowsErrorOnPagePropertyBiggerThanOne(): void { + $client = $this->givenClientWithResponses( + $this->createMultiplePagesResponse() + ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + + $this->expectExceptionMessage( 'Paging is not supported because each product should not have more than 4 payment intervals!' ); + $guzzlePaypalApi->listSubscriptionPlansForProduct( 'donation' ); + } + + public function testCreatesSubscriptionPlansForAProduct(): void { + $response = $this->createCreateSubscriptionPlanResponse(); + $client = $this->givenClientWithResponses( $response ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + $testPlan = new SubscriptionPlan( 'monthly', 'ServerPRODUCT-42', PaymentInterval::Monthly ); + + $createdPlan = $guzzlePaypalApi->createSubscriptionPlanForProduct( $testPlan ); + + $this->assertNotSame( $testPlan, $createdPlan, 'method should create a new subscription plan from server data' ); + $this->assertSame( 'ABCD-SERVER-GENERATED', $createdPlan->id, ); + $this->assertSame( 'ServerMonthly', $createdPlan->name ); + $this->assertSame( 'ServerPRODUCT-42', $createdPlan->productId ); + } + + public function testCreateSubscriptionForProductFailsWhenServerResponseHasMalformedJson(): void { + $logger = new LoggerSpy(); + $responseBody = '{"sss_reserved": "0"'; + $malformedJsonResponse = new Response( 200, [], $responseBody ); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( $malformedJsonResponse ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->createSubscriptionPlanForProduct( new SubscriptionPlan( 'Dummy', 'D1', PaymentInterval::HalfYearly ) ); + $this->fail( 'createSubscriptionPlanForProduct should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertJSONException( $e, $logger, $responseBody ); + } + } + + private function assertJSONException( PayPalAPIException $e, LoggerSpy $logger, string $responseBody ): void { + $this->assertStringContainsString( "Malformed JSON", $e->getMessage() ); + $firstCall = $logger->getFirstLogCall(); + $this->assertNotNull( $firstCall ); + $this->assertStringContainsString( "Malformed JSON", $firstCall->getMessage() ); + $this->assertArrayHasKey( 'serverResponse', $firstCall->getContext() ); + $this->assertSame( $responseBody, $firstCall->getContext()['serverResponse'] ); + } + + public function testCreateSubscriptionForProductFailsWhenServerResponseDoesNotContainSubscriptionData(): void { + $logger = new LoggerSpy(); + $responseBody = '{"error": "access denied" }'; + $malformedJsonResponse = new Response( 200, [], $responseBody ); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( $malformedJsonResponse ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->createSubscriptionPlanForProduct( new SubscriptionPlan( 'Dummy', 'D1', PaymentInterval::HalfYearly ) ); + $this->fail( 'createSubscriptionPlanForProduct should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertStringContainsString( "Server returned faulty subscription plan data", $e->getMessage() ); + $firstCall = $logger->getFirstLogCall(); + $this->assertNotNull( $firstCall ); + $this->assertStringContainsString( "Server returned faulty subscription plan data", $firstCall->getMessage() ); + $this->assertArrayHasKey( 'serverResponse', $firstCall->getContext() ); + $this->assertSame( $responseBody, $firstCall->getContext()['serverResponse'] ); + } + } + + /** + * @covers \WMDE\Fundraising\PaymentContext\Services\PayPal\Model\SubscriptionParameters + */ + public function testCreateSubscriptionBuildsJsonRequestFromSubscriptionParameters(): void { + $response = $this->createCreateSubscriptionResponse(); + $client = $this->givenClientWithResponses( $response ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + $testPlan = new SubscriptionPlan( 'monthly', 'ServerPRODUCT-42', PaymentInterval::Monthly, 'P-5ML4271244454362WXNWU5NQ' ); + + $fixedTimeStamp = new \DateTimeImmutable( '2018-11-01T00:00:00Z' ); + + $guzzlePaypalApi->createSubscription( + new SubscriptionParameters( + $testPlan, + Euro::newFromCents( 4223 ), + 'https://example.com/returnUrl', + 'https://example.com/cancelUrl', + $fixedTimeStamp + ) + ); + + $this->assertCount( 1, $this->guzzleHistory, 'We expect a create subscription request' ); + + /** @var Request $createRequest */ + $createRequest = $this->guzzleHistory[ 0 ][ 'request' ]; + + $this->assertSame( 'POST', $createRequest->getMethod() ); + $this->assertSame( self::BASIC_AUTH_HEADER, $createRequest->getHeaderLine( 'authorization' ) ); + $this->assertSame( + json_encode( json_decode( $this->readTestFixture( 'create_subscription_request.json' ) ), JSON_PRETTY_PRINT ), + json_encode( json_decode( $createRequest->getBody()->getContents() ), JSON_PRETTY_PRINT ) + ); + $this->assertSame( 'application/json', $createRequest->getHeaderLine( 'Content-Type' ) ); + $this->assertSame( 'application/json', $createRequest->getHeaderLine( 'Accept' ) ); + } + + /** + * @covers \WMDE\Fundraising\PaymentContext\Services\PayPal\Model\OrderParameters + */ + public function testCreateOrderBuildsJsonRequestFromOrderParameters(): void { + $response = $this->createCreateOrderResponse(); + $client = $this->givenClientWithResponses( $response ); + $guzzlePaypalApi = new GuzzlePaypalAPI( $client, 'testUserName', 'testPassword', new NullLogger() ); + $guzzlePaypalApi->createOrder( + new OrderParameters( + "D-78945ABCD", + "78945ABCD", + "Spende an Wikimedia Deutschland", + Euro::newFromCents( 4223 ), + 'https://example.com/returnUrl', + 'https://example.com/cancelUrl' + ) + ); + + $this->assertCount( 1, $this->guzzleHistory, 'We expect a create order request' ); + + /** @var Request $createRequest */ + $createRequest = $this->guzzleHistory[ 0 ][ 'request' ]; + + $this->assertSame( 'POST', $createRequest->getMethod() ); + $this->assertSame( self::BASIC_AUTH_HEADER, $createRequest->getHeaderLine( 'authorization' ) ); + $this->assertSame( + json_encode( json_decode( $this->readTestFixture( 'create_order_request.json' ) ), JSON_PRETTY_PRINT ), + json_encode( json_decode( $createRequest->getBody()->getContents() ), JSON_PRETTY_PRINT ) + ); + $this->assertSame( 'application/json', $createRequest->getHeaderLine( 'Content-Type' ) ); + $this->assertSame( 'application/json', $createRequest->getHeaderLine( 'Accept' ) ); + } + + public function testApiConvertsBadResponseIntoApiException(): void { + $logger = new LoggerSpy(); + $guzzlePaypalApi = new GuzzlePaypalAPI( + $this->givenClientWithResponses( new Response( 400, [], 'No Cookies or cupcakes in request! Cookie monster sad!' ) ), + 'testUserName', + 'testPassword', + $logger + ); + + try { + $guzzlePaypalApi->createProduct( new Product( 'D1', 'Dummy', ) ); + $this->fail( 'createProduct should throw an exception' ); + } catch ( PayPalAPIException $e ) { + $this->assertStringContainsString( 'Server rejected request', $e->getMessage() ); + $log = $logger->getFirstLogCall(); + $this->assertNotNull( $log ); + $context = $log->getContext(); + $this->assertArrayHasKey( 'serverResponse', $context ); + $this->assertArrayHasKey( 'error', $context ); + $this->assertArrayHasKey( 'requestBody', $context ); + } + } + + private function givenClientWithResponses( Response ...$responses ): Client { + $mock = new MockHandler( array_values( $responses ) ); + $history = Middleware::history( $this->guzzleHistory ); + $handlerStack = HandlerStack::create( $mock ); + $handlerStack->push( $history ); + + return new Client( [ 'handler' => $handlerStack ] ); + } + + private function createEmptyProductResponse(): Response { + return new Response( + 200, + [], + <<readTestFixture( 'list_plans_response.json' ) ); + } + + private function readTestFixture( string $fileName ): string { + $validJSONResponseContent = file_get_contents( __DIR__ . '/../../../Data/PaypalAPI/' . $fileName ); + if ( $validJSONResponseContent === false ) { + throw new RuntimeException( ' could not read fixture file ' . __DIR__ . '/../../../Data/PaypalAPI/' . $fileName ); + } + return $validJSONResponseContent; + } + + private function createCreateSubscriptionPlanResponse(): Response { + return new Response( 200, [], $this->readTestFixture( 'create_plans_response.json' ) ); + } + + private function createUndefinedPlansPropertyResponse(): Response { + return new Response( + 200, + [], + << '1', + 'links' => [ + [ + "href" => "https://api-m.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel" => "self", + "method" => "GET" + ], + [ + "href" => "https://www.paypal.com/checkoutnow?token=5O190127TN364715T", + "rel" => "payer-action", + "method" => "GET" + ] + ] + ] ); + + $this->assertEquals( + new Order( + '1', + 'https://www.paypal.com/checkoutnow?token=5O190127TN364715T' + ), + $subscription + ); + } + + /** + * @dataProvider responsesMissingProperID + * @param array $responseBody + */ + public function testIdIsRequiredField( array $responseBody, string $exceptionMessage ): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( $exceptionMessage ); + $order = Order::from( $responseBody ); + } + + /** + * @return iterable,string}> + */ + public static function responsesMissingProperID(): iterable { + yield [ [ "id" => null ], 'Field "id" is required!' ]; + yield [ [ "id" => false ], "Id is not a valid string!" ]; + yield [ [ "id" => "" ], "Id is not a valid string!" ]; + yield [ [ "blabla" => "bla" ], 'Field "id" is required!' ]; + yield [ [], 'Field "id" is required!' ]; + } + + public function testUnsetLinksThrowsException(): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( "Fields must contain array with links!" ); + order::from( [ 'id' => 'id-5' ] ); + } + + public function testMissingUserActionLinksThrowsAnException(): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( "Link array did not contain approve link!" ); + Order::from( [ + 'id' => 'id-5', + 'links' => [ + [ + "href" => "https://api-m.paypal.com/v2/checkout/orders/5O190127TN364715T", + "rel" => "self", + "method" => "GET" + ] + ] + ] ); + } +} diff --git a/tests/Unit/Services/PayPal/Model/ProductTest.php b/tests/Unit/Services/PayPal/Model/ProductTest.php new file mode 100644 index 00000000..695dd4e8 --- /dev/null +++ b/tests/Unit/Services/PayPal/Model/ProductTest.php @@ -0,0 +1,33 @@ +toJSON(); + + $this->assertSame( + '{"name":"SerializationName","id":"SerializationID","description":"SerializationDescription","category":"NONPROFIT","type":"SERVICE"}', + $actualJSONOutput + ); + } + + public function testNameMustNotBeEmptyString(): void { + $this->expectException( \UnexpectedValueException::class ); + $product = new Product( '', 'bla', '' ); + } + + public function testIdMustNotBeEmptyString(): void { + $this->expectException( \UnexpectedValueException::class ); + $product = new Product( 'bla', '', '' ); + } +} diff --git a/tests/Unit/Services/PayPal/Model/SubscriptionPlanTest.php b/tests/Unit/Services/PayPal/Model/SubscriptionPlanTest.php new file mode 100644 index 00000000..97767404 --- /dev/null +++ b/tests/Unit/Services/PayPal/Model/SubscriptionPlanTest.php @@ -0,0 +1,178 @@ +toJSON(), true, 512, JSON_THROW_ON_ERROR ); + + $this->assertSame( + [ + 'name' => 'Monthly Membership Payment', + 'product_id' => 'membership-2023', + 'description' => 'Membership Payment, billed monthly', + 'billing_cycles' => [ [ + 'sequence' => 1, + "pricing_scheme" => [ + "fixed_price" => [ + "value" => "1", + "currency_code" => "EUR" + ] + ], + 'tenure_type' => 'REGULAR', + 'frequency' => [ + 'interval_unit' => 'MONTH', + 'interval_count' => 1 + ], + 'total_cycles' => 0 + ] ], + 'payment_preferences' => [ + 'auto_bill_outstanding' => true, + 'setup_fee_failure_action' => 'CONTINUE', + 'payment_failure_threshold' => 0, + 'setup_fee' => [ + 'currency_code' => 'EUR', + 'value' => '0' + ] + ] + ], + $serializedPlan + ); + } + + public function testCreateFromJSON(): void { + $plan = SubscriptionPlan::from( [ + 'id' => 'FAKE_GENERATED_ID', + 'name' => 'Yearly Membership Payment', + 'product_id' => 'membership-2023', + 'description' => 'Membership Payment, billed yearly', + 'billing_cycles' => [ [ + 'sequence' => 0, + 'tenure_type' => 'REGULAR', + 'frequency' => [ + 'interval_unit' => 'MONTH', + 'interval_count' => 12 + ], + 'total_cycles' => 0 + ] ], + 'payment_preferences' => [ + 'auto_bill_outstanding' => true, + 'setup_fee_failure_action' => 'CONTINUE', + 'payment_failure_threshold' => 0, + 'setup_fee' => [ + 'currency_code' => 'EUR', + 'value' => '0' + ] + ] + ] ); + + $this->assertSame( 'Yearly Membership Payment', $plan->name ); + $this->assertSame( 'FAKE_GENERATED_ID', $plan->id ); + $this->assertSame( 'membership-2023', $plan->productId ); + $this->assertSame( 'Membership Payment, billed yearly', $plan->description ); + $this->assertSame( PaymentInterval::Yearly, $plan->monthlyInterval ); + } + + /** + * @dataProvider brokenBillingCycleDataProvider + */ + public function testCreateFromJSONFailsOnReadingIntervalFromBillingCycle( mixed $testBillingCycleValues, string $exptectedExceptionmessage ): void { + $this->expectExceptionMessage( $exptectedExceptionmessage ); + $plan = SubscriptionPlan::from( [ + 'id' => 'FAKE_GENERATED_ID', + 'name' => 'Yearly Membership Payment', + 'product_id' => 'membership-2023', + 'description' => 'Membership Payment, billed yearly', + 'billing_cycles' => $testBillingCycleValues, + 'payment_preferences' => [ + 'auto_bill_outstanding' => true, + 'setup_fee_failure_action' => 'CONTINUE', + 'payment_failure_threshold' => 0, + 'setup_fee' => [ + 'currency_code' => 'EUR', + 'value' => '0' + ] + ] + ] ); + } + + /** + * @return iterable + */ + public static function brokenBillingCycleDataProvider(): iterable { + yield 'passing null' => [ null, 'Wrong billing cycle data' ]; + yield 'passing a string' => [ 'hallo', 'Wrong billing cycle data' ]; + yield 'empty billing cycles' => [ [], 'Wrong billing cycle data' ]; + yield 'too many billing cycles' => [ + [ [ + 'sequence' => 0, + 'tenure_type' => 'REGULAR', + 'frequency' => [ + 'interval_unit' => 'MONTH', + 'interval_count' => 12 + ], + 'total_cycles' => 0 + ], + [ + 'sequence' => 1, + 'tenure_type' => 'REGULAR', + 'frequency' => [ + 'interval_unit' => 'MONTH', + 'interval_count' => 12 + ], + 'total_cycles' => 0 + ] ], + 'Wrong billing cycle data' + ]; + + yield 'missing "interval_count" field' => [ + [ [ + 'sequence' => 0, + 'tenure_type' => 'REGULAR', + 'frequency' => [ + 'interval_unit' => 'MONTH', + ], + 'total_cycles' => 0 + ] ], + 'Wrong frequency data in billing cycle' + ]; + + yield 'missing "frequency" field' => [ + [ [ + 'sequence' => 0, + 'tenure_type' => 'REGULAR', + 'total_cycles' => 0 + ] ], + 'Wrong frequency data in billing cycle' + ]; + + yield '"interval_unit" field is not set to MONTH' => [ + [ [ + 'sequence' => 0, + 'tenure_type' => 'REGULAR', + 'frequency' => [ + 'interval_unit' => 'DAY', + 'interval_count' => 12 + ], + 'total_cycles' => 0 + ] ], + 'interval_unit must be MONTH' + ]; + } +} diff --git a/tests/Unit/Services/PayPal/Model/SubscriptionTest.php b/tests/Unit/Services/PayPal/Model/SubscriptionTest.php new file mode 100644 index 00000000..5362ca2e --- /dev/null +++ b/tests/Unit/Services/PayPal/Model/SubscriptionTest.php @@ -0,0 +1,103 @@ + '1', + 'start_time' => '2023-12-24T01:02:03Z', + 'links' => [ + [ + "href" => "https://api-m.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G", + "rel" => "self", + "method" => "GET" + ], + [ + "href" => "https://www.paypal.com/webapps/billing/subscriptions?ba_token=BA-2M539689T3856352J", + "rel" => "approve", + "method" => "GET" + ] + ] + ] ); + + $this->assertEquals( + new Subscription( + '1', + new \DateTimeImmutable( '2023-12-24T01:02:03Z' ), + 'https://www.paypal.com/webapps/billing/subscriptions?ba_token=BA-2M539689T3856352J' + ), + $subscription + ); + } + + /** + * @dataProvider responsesWithMissingFields + * @param array $apiResponse + */ + public function testIdAndStartTimeAreRequiredField( array $apiResponse ): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( 'Fields "id" and "start_time" are required' ); + Subscription::from( $apiResponse ); + } + + /** + * @return iterable}> + */ + public static function responsesWithMissingFields(): iterable { + yield [ [ 'start_time' => '2023-01-02T03:04:05Z' ] ]; + yield [ [ 'id' => 'id1' ] ]; + yield [ [] ]; + yield [ [ 'id' => false ] ]; + } + + /** + * @dataProvider malformedDates + */ + public function testMalformedStartTimeThrowsAnException( mixed $malformedDate ): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( 'Malformed date formate for start_time' ); + Subscription::from( [ 'id' => 'id-5', 'start_time' => $malformedDate ] ); + } + + /** + * @return iterable + */ + public static function malformedDates(): iterable { + yield [ 123456576 ]; + yield [ '' ]; + yield [ 'bad date' ]; + } + + public function testUnsetLinksThrowsException(): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( "Fields must contain array with links!" ); + Subscription::from( [ 'id' => 'id-5', 'start_time' => '2023-01-02T03:04:05Z' ] ); + } + + public function testMissingApprovalLinksThrowsAnException(): void { + $this->expectException( PayPalAPIException::class ); + $this->expectExceptionMessage( "Link array did not contain approval link!" ); + Subscription::from( [ + 'id' => 'id-5', + 'start_time' => '2023-01-02T03:04:05Z', + 'links' => [ + [ + "href" => "https://api-m.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G", + "rel" => "self", + "method" => "GET" + ] + ] + ] ); + } + +} diff --git a/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterConfigFactoryTest.php b/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterConfigFactoryTest.php new file mode 100644 index 00000000..ed05a3a1 --- /dev/null +++ b/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterConfigFactoryTest.php @@ -0,0 +1,79 @@ +givenConfig(), 'donation', 'en' ); + + $this->assertSame( 'Donation', $config->productName ); + $this->assertSame( 'https://example.com/return', $config->returnURL ); + $this->assertSame( 'https://example.com/cancel', $config->cancelURL ); + $this->assertCount( 2, $config->subscriptionPlanMap ); + $this->assertArrayHasKey( PaymentInterval::Monthly->name, $config->subscriptionPlanMap ); + $this->assertEquals( + new SubscriptionPlan( + 'Monthly donation', + 'paypal_product_id_1', + PaymentInterval::Monthly, + 'F00' + ), + $config->subscriptionPlanMap[PaymentInterval::Monthly->name] + ); + // TODO check yearly plan + } + + public function testWhenProductKeyDoesNotExistAnExceptionIsThrown(): void { + $this->expectException( \LogicException::class ); + $this->expectExceptionMessage( "'membership' does not exist in PayPal API configuration. Please check your configuration file." ); + + PayPalPaymentProviderAdapterConfigFactory::createConfig( $this->givenConfig(), 'membership', 'en' ); + } + + public function testWhenLanguageKeyDoesNotExistAnExceptionIsThrown(): void { + $this->expectException( \LogicException::class ); + $this->expectExceptionMessage( "'de' does not exist in PayPal API configuration for product 'donation'. Please check your configuration file." ); + + PayPalPaymentProviderAdapterConfigFactory::createConfig( $this->givenConfig(), 'donation', 'de' ); + } + + /** + * @phpstan-ignore-next-line + */ + private function givenConfig(): array { + return [ + 'donation' => [ + // no 'de' language to test error checking + 'en' => [ + 'product_id' => 'paypal_product_id_1', + 'product_name' => 'Donation', + 'return_url' => 'https://example.com/return', + 'cancel_url' => 'https://example.com/cancel', + 'subscription_plans' => [ + [ + 'id' => 'F00', + 'name' => 'Monthly donation', + 'interval' => 'Monthly' + ], + [ + 'id' => 'F11', + 'name' => 'Yearly donation', + 'interval' => 'Yearly' + ] + ] + ] + ], + // no memberships here to allow reuse of this fixture for error checking tests + ]; + } +} diff --git a/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterConfigSchemaTest.php b/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterConfigSchemaTest.php new file mode 100644 index 00000000..1cdaf053 --- /dev/null +++ b/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterConfigSchemaTest.php @@ -0,0 +1,30 @@ +processConfiguration( + $schema, + [ $config ] + ); + } +} diff --git a/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterTest.php b/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterTest.php new file mode 100644 index 00000000..b576c76a --- /dev/null +++ b/tests/Unit/Services/PayPal/PayPalPaymentProviderAdapterTest.php @@ -0,0 +1,235 @@ +givenAPIExpectingCreateSubscription(), + $this->givenAdapterConfig(), + $this->givenRepositoryStub(), + new FakeUrlAuthenticator() + ); + $payment = new PayPalPayment( 6, Euro::newFromInt( 100 ), PaymentInterval::Quarterly ); + + $urlGenerator = $adapter->modifyPaymentUrlGenerator( new IncompletePayPalURLGenerator( $payment ), DomainSpecificContextForTesting::create() ); + + $this->assertSame( 'https://sandbox.paypal.com/confirm-subscription', $urlGenerator->generateURL( new DomainSpecificContext( 6 ) ) ); + } + + public function testGivenOneTimePaymentURLGeneratorIsReplacedWithPayPalUrlGeneratorFetchedFromAPI(): void { + $adapter = new PayPalPaymentProviderAdapter( + $this->givenAPIExpectingCreateOrder(), + $this->givenAdapterConfig(), + $this->givenRepositoryStub(), + new FakeUrlAuthenticator() + ); + $payment = new PayPalPayment( 4, Euro::newFromInt( 27 ), PaymentInterval::OneTime ); + + $urlGenerator = $adapter->modifyPaymentUrlGenerator( new IncompletePayPalURLGenerator( $payment ), DomainSpecificContextForTesting::create() ); + + $this->assertSame( 'https://sandbox.paypal.com/confirm-order', $urlGenerator->generateURL( new DomainSpecificContext( 4 ) ) ); + } + + public function testAdapterOnlyAcceptsIncompletePayPalUrlGenerator(): void { + $this->expectException( \LogicException::class ); + $this->expectExceptionMessage( 'Expected instance of ' . IncompletePayPalURLGenerator::class . ', got ' . PayPalURLGenerator::class ); + $api = $this->createStub( PaypalAPI::class ); + $adapter = new PayPalPaymentProviderAdapter( + $api, + $this->givenAdapterConfig(), + $this->givenRepositoryStub(), + new FakeUrlAuthenticator() + ); + + $adapter->modifyPaymentUrlGenerator( new PayPalURLGenerator( 'https://example.com' ), DomainSpecificContextForTesting::create() ); + } + + public function testGivenRecurringPaymentAdapterStoresPayPalSubscription(): void { + $payment = new PayPalPayment( 6, Euro::newFromInt( 100 ), PaymentInterval::HalfYearly ); + $payPalSubscription = new PayPalSubscription( $payment, self::SUBSCRIPTION_ID ); + $repo = $this->createMock( PayPalPaymentIdentifierRepository::class ); + $repo->expects( $this->once() )->method( 'storePayPalIdentifier' )->with( $payPalSubscription ); + $adapter = new PayPalPaymentProviderAdapter( + $this->givenAPIExpectingCreateSubscription(), + $this->givenAdapterConfig(), + $repo, + new FakeUrlAuthenticator() + ); + + $returnedPayment = $adapter->fetchAndStoreAdditionalData( $payment, DomainSpecificContextForTesting::create() ); + + $this->assertSame( $payment, $returnedPayment ); + } + + public function testGivenOneTimePaymentAdapterStoresPayPalOrder(): void { + $payment = new PayPalPayment( 74, Euro::newFromInt( 470 ), PaymentInterval::OneTime ); + $paypalOrder = new PayPalOrder( $payment, self::ORDER_ID ); + $repo = $this->createMock( PayPalPaymentIdentifierRepository::class ); + $repo->expects( $this->once() )->method( 'storePayPalIdentifier' )->with( $paypalOrder ); + $adapter = new PayPalPaymentProviderAdapter( + $this->givenAPIExpectingCreateOrder(), + $this->givenAdapterConfig(), + $repo, + new FakeUrlAuthenticator() + ); + + $returnedPayment = $adapter->fetchAndStoreAdditionalData( $payment, DomainSpecificContextForTesting::create() ); + + $this->assertSame( $payment, $returnedPayment ); + } + + public function testAdapterOnlyAcceptsPayPalPayments(): void { + $this->expectException( \LogicException::class ); + $this->expectExceptionMessage( PayPalPaymentProviderAdapter::class . ' only accepts ' . PayPalPayment::class ); + $api = $this->createStub( PaypalAPI::class ); + $adapter = new PayPalPaymentProviderAdapter( + $api, + $this->givenAdapterConfig(), + $this->givenRepositoryStub(), + new FakeUrlAuthenticator() + ); + + $adapter->fetchAndStoreAdditionalData( + SofortPayment::create( 5, Euro::newFromCents( 4775 ), PaymentInterval::OneTime, new FakePaymentReferenceCode() ), + DomainSpecificContextForTesting::create() + ); + } + + public function testAdapterCallsAPIOnlyOnce(): void { + $repo = $this->createStub( PayPalPaymentIdentifierRepository::class ); + $adapter = new PayPalPaymentProviderAdapter( + $this->givenAPIExpectingCreateSubscription(), + $this->givenAdapterConfig(), + $repo, + new FakeUrlAuthenticator() + ); + $payment = new PayPalPayment( 6, Euro::newFromInt( 100 ), PaymentInterval::Quarterly ); + $context = DomainSpecificContextForTesting::create(); + + $adapter->modifyPaymentUrlGenerator( new IncompletePayPalURLGenerator( $payment ), $context ); + $adapter->fetchAndStoreAdditionalData( $payment, $context ); + $adapter->modifyPaymentUrlGenerator( new IncompletePayPalURLGenerator( $payment ), $context ); + } + + public function testReplacesPlaceholdersInConfig(): void { + $fakePayPalAPI = new FakePayPalAPIForPayments( + [ new Subscription( self::SUBSCRIPTION_ID, new \DateTimeImmutable(), 'https://sandbox.paypal.com/confirm-subscription' ) ], + [ new Order( self::ORDER_ID, 'https://sandbox.paypal.com/confirm-order' ) ], + ); + $adapter = new PayPalPaymentProviderAdapter( + $fakePayPalAPI, + $this->givenAdapterConfig(), + $this->createStub( PayPalPaymentIdentifierRepository::class ), + new FakeUrlAuthenticator() + ); + $recurringPayment = new PayPalPayment( 7, Euro::newFromInt( 20 ), PaymentInterval::Quarterly ); + $oneTimePayment = new PayPalPayment( 8, Euro::newFromInt( 1000 ), PaymentInterval::OneTime ); + $context = DomainSpecificContextForTesting::create(); + + $adapter->modifyPaymentUrlGenerator( new IncompletePayPalURLGenerator( $recurringPayment ), $context ); + $adapter->modifyPaymentUrlGenerator( new IncompletePayPalURLGenerator( $oneTimePayment ), $context ); + + $subscriptionParameters = $fakePayPalAPI->getSubscriptionParameters(); + $orderParameters = $fakePayPalAPI->getOrderParameters(); + $this->assertCount( 1, $subscriptionParameters ); + $this->assertCount( 1, $orderParameters ); + $this->assertSame( 'https://example.com/confirmed?testAccessToken=LET_ME_IN', $subscriptionParameters[0]->returnUrl ); + } + + public function testGivenLegacyPayPalUrlGeneratorAdapterDoesNotModifyUrlGenerator(): void { + $adapter = new PayPalPaymentProviderAdapter( + $this->givenAPIExpectingNoCalls(), + $this->givenAdapterConfig(), + $this->givenRepositoryStub(), + new FakeUrlAuthenticator() + ); + $payment = new PayPalPayment( 6, Euro::newFromInt( 100 ), PaymentInterval::Quarterly ); + $legacyUrlGenerator = new LegacyPayPalURLGenerator( + FakeLegacyPayPalURLGeneratorConfig::create(), + new FakeUrlAuthenticator(), + $payment + ); + + $urlGenerator = $adapter->modifyPaymentUrlGenerator( $legacyUrlGenerator, DomainSpecificContextForTesting::create() ); + + $this->assertSame( $legacyUrlGenerator, $urlGenerator ); + } + + private function givenAdapterConfig(): PayPalPaymentProviderAdapterConfig { + return new PayPalPaymentProviderAdapterConfig( + 'your donation', + 'https://example.com/confirmed?', + 'https://example.com/new', + [ + PaymentInterval::Monthly->name => new SubscriptionPlan( 'Monthly donation', 'Donation-1', PaymentInterval::Monthly, 'P-123' ), + PaymentInterval::Quarterly->name => new SubscriptionPlan( 'Quarterly donation', 'Donation-1', PaymentInterval::Quarterly, 'P-456' ), + PaymentInterval::HalfYearly->name => new SubscriptionPlan( 'Half-yearly donation', 'Donation-1', PaymentInterval::HalfYearly, 'P-789' ), + PaymentInterval::Yearly->name => new SubscriptionPlan( 'Yearly donation', 'Donation-1', PaymentInterval::Yearly, 'P-ABC' ) + ] + ); + } + + private function givenRepositoryStub(): PayPalPaymentIdentifierRepository { + $stub = $this->createMock( PayPalPaymentIdentifierRepository::class ); + $stub->expects( $this->never() )->method( 'storePayPalIdentifier' ); + return $stub; + } + + private function givenAPIExpectingCreateOrder(): PaypalAPI { + $api = $this->createStub( PayPalAPI::class ); + $api->method( 'createOrder' )->willReturn( + new Order( self::ORDER_ID, 'https://sandbox.paypal.com/confirm-order' ) + ); + return $api; + } + + private function givenAPIExpectingCreateSubscription(): PaypalAPI { + $api = $this->createMock( PayPalAPI::class ); + $api->expects( $this->once() ) + ->method( 'createSubscription' ) + ->willReturn( + new Subscription( self::SUBSCRIPTION_ID, new \DateTimeImmutable(), 'https://sandbox.paypal.com/confirm-subscription' ) + ); + return $api; + } + + private function givenAPIExpectingNoCalls(): PaypalAPI { + $api = $this->createMock( PayPalAPI::class ); + $api->expects( $this->never() )->method( 'createSubscription' ); + $api->expects( $this->never() )->method( 'createOrder' ); + return $api; + } +} diff --git a/tests/Unit/Services/PayPal/PayPalPaymentProviderConfigReaderTest.php b/tests/Unit/Services/PayPal/PayPalPaymentProviderConfigReaderTest.php new file mode 100644 index 00000000..7a9bf800 --- /dev/null +++ b/tests/Unit/Services/PayPal/PayPalPaymentProviderConfigReaderTest.php @@ -0,0 +1,55 @@ +assertArrayHasKey( 'donation', $config ); + } + + public function testProductIdsMustBeUnique(): void { + $this->expectException( \DomainException::class ); + $this->expectExceptionMessage( "All product IDs in the configuration file must be unique!" ); + PayPalPaymentProviderAdapterConfigReader::readConfig( __DIR__ . '/../../../Data/PayPalAPIURLGeneratorConfig/paypal_api_duplicate_product_id.yml' ); + } + + public function testSubscriptionPlanIdsMustBeUnique(): void { + $this->expectException( \DomainException::class ); + $this->expectExceptionMessage( "All subscription plan IDs in the configuration file must be unique!" ); + PayPalPaymentProviderAdapterConfigReader::readConfig( __DIR__ . '/../../../Data/PayPalAPIURLGeneratorConfig/paypal_api_duplicate_plan_id.yml' ); + } + + public function testFileMustContainAtLeastOneLanguage(): void { + $this->expectException( InvalidConfigurationException::class ); + $this->expectExceptionMessage( 'The path "paypal_api.donation" should have at least 1 element(s) defined.' ); + PayPalPaymentProviderAdapterConfigReader::readConfig( __DIR__ . '/../../../Data/PayPalAPIURLGeneratorConfig/paypal_api_no_language.yml' ); + } + + public function testFileMustContainAtLeastOnePlan(): void { + $this->expectException( InvalidConfigurationException::class ); + $this->expectExceptionMessage( 'The child config "product_id" under "paypal_api.donation.de_DE" must be configured.' ); + PayPalPaymentProviderAdapterConfigReader::readConfig( __DIR__ . '/../../../Data/PayPalAPIURLGeneratorConfig/paypal_api_no_plans.yml' ); + } + + public function testReadingFromEmptyFileThrowsException(): void { + $this->expectException( \DomainException::class ); + $this->expectExceptionMessage( 'Configuration file must contain a nested array structure!' ); + PayPalPaymentProviderAdapterConfigReader::readConfig( __DIR__ . '/../../../Data/PayPalAPIURLGeneratorConfig/paypal_api_empty.yml' ); + } + + public function testReadingFromNonexistentFileThrowsException(): void { + $this->expectException( ParseException::class ); + $this->expectExceptionMessageMatches( '#does/not/exist/plan.yml" does not exist#' ); + PayPalPaymentProviderAdapterConfigReader::readConfig( __DIR__ . '/this/path/does/not/exist/plan.yml' ); + } +} diff --git a/tests/Unit/Services/PaymentProviderAdapterFactoryImplementationTest.php b/tests/Unit/Services/PaymentProviderAdapterFactoryImplementationTest.php new file mode 100644 index 00000000..bb592f06 --- /dev/null +++ b/tests/Unit/Services/PaymentProviderAdapterFactoryImplementationTest.php @@ -0,0 +1,62 @@ +createStub( PaypalAPI::class ), + $this->createStub( PayPalPaymentProviderAdapterConfig::class ), + $this->createStub( PayPalPaymentIdentifierRepository::class ), + ); + $adapter = $factory->createProvider( $payment, new FakeUrlAuthenticator() ); + $this->assertInstanceOf( DefaultPaymentProviderAdapter::class, $adapter ); + } + + public function testItCreatedPayPalAdapterForPayPalPayments(): void { + $factory = new PaymentProviderAdapterFactoryImplementation( + $this->createStub( PaypalAPI::class ), + $this->createStub( PayPalPaymentProviderAdapterConfig::class ), + $this->createStub( PayPalPaymentIdentifierRepository::class ), + ); + $payment = new PayPalPayment( 5, Euro::newFromCents( 10000 ), PaymentInterval::Yearly ); + $adapter = $factory->createProvider( $payment, new FakeUrlAuthenticator() ); + $this->assertInstanceOf( PayPalPaymentProviderAdapter::class, $adapter ); + } + + /** + * @return iterable + */ + public static function allPaymentsExceptPayPal(): iterable { + yield 'bank transfer payment' => [ BankTransferPayment::create( 1, Euro::newFromCents( 100 ), PaymentInterval::Monthly, new FakePaymentReferenceCode() ) ]; + yield 'credit card payment' => [ new CreditCardPayment( 2, Euro::newFromCents( 100 ), PaymentInterval::Yearly ) ]; + yield 'direct debit payment' => [ DirectDebitPayment::create( 3, Euro::newFromCents( 100 ), PaymentInterval::HalfYearly, new TestIban(), '' ) ]; + yield 'sofort payment' => [ SofortPayment::create( 4, Euro::newFromCents( 100 ), PaymentInterval::OneTime, new FakePaymentReferenceCode() ) ]; + } +} diff --git a/tests/Unit/Services/PaymentURLFactoryTest.php b/tests/Unit/Services/PaymentURLFactoryTest.php new file mode 100644 index 00000000..ea21d0e3 --- /dev/null +++ b/tests/Unit/Services/PaymentURLFactoryTest.php @@ -0,0 +1,131 @@ +createTestURLFactory(); + $payment = SofortPayment::create( + 1, + Euro::newFromCents( 1000 ), + PaymentInterval::OneTime, + new PaymentReferenceCode( 'XW', 'DARE99', 'X' ) + ); + + $actualGenerator = $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + + self::assertInstanceOf( SofortURLGenerator::class, $actualGenerator ); + } + + public function testPaymentURLFactoryCreatesCreditCardURLGenerator(): void { + $urlFactory = $this->createTestURLFactory(); + $payment = new CreditCardPayment( 1, Euro::newFromInt( 99 ), PaymentInterval::Quarterly ); + + $actualGenerator = $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + + self::assertInstanceOf( CreditCardURLGenerator::class, $actualGenerator ); + } + + /** + * This test check the creation of the legacy URL generator, + * remove when the application has switched completely to the PayPal API, + * and we don't need the feature flag any more + * (see https://phabricator.wikimedia.org/T329159 ) + * + * @deprecated This test runs with the legacy feature flag + */ + public function testPaymentURLFactoryCreatesLegacyPayPalURLGenerator(): void { + $urlFactory = $this->createTestURLFactory( true ); + $payment = new PayPalPayment( 1, Euro::newFromInt( 99 ), PaymentInterval::Quarterly ); + + $actualGenerator = $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + + self::assertInstanceOf( LegacyPayPalURLGenerator::class, $actualGenerator ); + } + + public function testPaymentURLFactoryCreatesIncompletePayPalURLGenerator(): void { + $urlFactory = $this->createTestURLFactory(); + $payment = new PayPalPayment( 1, Euro::newFromInt( 99 ), PaymentInterval::Quarterly ); + + $actualGenerator = $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + + // The IncompletePayPalURLGenerator will be replaced inside the use case, we just need a default for PayPal + self::assertInstanceOf( IncompletePayPalURLGenerator::class, $actualGenerator ); + } + + public function testPaymentURLFactoryCreatesConfirmationPageUrlGeneratorForDirectDebit(): void { + $urlFactory = $this->createTestURLFactory(); + $payment = DirectDebitPayment::create( 1, Euro::newFromInt( 99 ), PaymentInterval::OneTime, new TestIban(), '' ); + + $actualGenerator = $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + + self::assertInstanceOf( ConfirmationPageUrlGenerator::class, $actualGenerator ); + } + + public function testPaymentURLFactoryCreatesConfirmationPageUrlGeneratorForBankTransfer(): void { + $urlFactory = $this->createTestURLFactory(); + $payment = BankTransferPayment::create( 1, + Euro::newFromInt( 99 ), + PaymentInterval::OneTime, + new PaymentReferenceCode( 'XW', 'DARE99', 'X' ) + ); + + $actualGenerator = $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + + self::assertInstanceOf( ConfirmationPageUrlGenerator::class, $actualGenerator ); + } + + public function testPaymentURLFactoryThrowsExceptionOnUnknownPaymentType(): void { + $urlFactory = $this->createTestURLFactory(); + $payment = $this->createMock( Payment::class ); + + $this->expectException( \InvalidArgumentException::class ); + $urlFactory->createURLGenerator( $payment, new FakeUrlAuthenticator() ); + } + + private function createTestURLFactory( bool $useLegacyPayPalUrlGenerator = false ): PaymentURLFactory { + $creditCardConfig = $this->createStub( CreditCardURLGeneratorConfig::class ); + $payPalConfig = $this->createStub( LegacyPayPalURLGeneratorConfig::class ); + $sofortConfig = $this->createStub( SofortURLGeneratorConfig::class ); + $sofortClient = $this->createStub( SofortClient::class ); + return new PaymentURLFactory( + $creditCardConfig, + $payPalConfig, + $sofortConfig, + $sofortClient, + self::CONFIRMATION_PAGE_URL, + $useLegacyPayPalUrlGenerator + ); + } +} diff --git a/tests/Unit/Services/PaymentUrlGenerator/ConfirmationPageUrlGeneratorTest.php b/tests/Unit/Services/PaymentUrlGenerator/ConfirmationPageUrlGeneratorTest.php new file mode 100644 index 00000000..4fd1b5b6 --- /dev/null +++ b/tests/Unit/Services/PaymentUrlGenerator/ConfirmationPageUrlGeneratorTest.php @@ -0,0 +1,27 @@ +assertSame( + 'https://spenden.wikimedia.de/confirmation?testAccessToken=LET_ME_IN', + $generator->generateURL( new DomainSpecificContext( 1, ) ) + ); + } + +} diff --git a/tests/Unit/Domain/PaymentUrlGenerator/CreditCardTest.php b/tests/Unit/Services/PaymentUrlGenerator/CreditCardURLGeneratorTest.php similarity index 72% rename from tests/Unit/Domain/PaymentUrlGenerator/CreditCardTest.php rename to tests/Unit/Services/PaymentUrlGenerator/CreditCardURLGeneratorTest.php index 0a1120de..bb563add 100644 --- a/tests/Unit/Domain/PaymentUrlGenerator/CreditCardTest.php +++ b/tests/Unit/Services/PaymentUrlGenerator/CreditCardURLGeneratorTest.php @@ -2,21 +2,22 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Tests\Unit\Domain\PaymentUrlGenerator; +namespace Unit\Services\PaymentUrlGenerator; use PHPUnit\Framework\TestCase; use WMDE\Euro\Euro; use WMDE\Fundraising\PaymentContext\Domain\Model\CreditCardPayment; use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentInterval; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\CreditCard; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\CreditCardConfig; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\RequestContext; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\TranslatableDescription; +use WMDE\Fundraising\PaymentContext\Domain\UrlGenerator\DomainSpecificContext; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\CreditCardURLGenerator; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\CreditCardURLGeneratorConfig; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\TranslatableDescription; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FakeUrlAuthenticator; /** - * @covers \WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\CreditCard + * @covers \WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\CreditCardURLGenerator */ -class CreditCardTest extends TestCase { +class CreditCardURLGeneratorTest extends TestCase { /** @dataProvider donationProvider */ public function testUrlGeneration( @@ -34,8 +35,8 @@ public function testUrlGeneration( $translatableDescriptionMock = $this->createMock( TranslatableDescription::class ); $translatableDescriptionMock->method( 'getText' )->willReturn( $description ); - $urlGenerator = new CreditCard( - CreditCardConfig::newFromConfig( + $urlGenerator = new CreditCardURLGenerator( + CreditCardURLGeneratorConfig::newFromConfig( [ 'base-url' => 'https://credit-card.micropayment.de/creditcard/event/index.php?', 'project-id' => 'wikimedia', @@ -47,13 +48,12 @@ public function testUrlGeneration( ], $translatableDescriptionMock ), + new FakeUrlAuthenticator(), new CreditCardPayment( 42, $amount, $interval ) ); - $requestContext = new RequestContext( + $requestContext = new DomainSpecificContext( itemId: $donationId, - updateToken: $updateToken, - accessToken: $accessToken, firstName: $firstName, lastName: $lastName, ); @@ -66,8 +66,8 @@ public function testUrlGeneration( public function testWhenTestModeIsEnabled_urlPassesProperParameter(): void { $translatableDescriptionMock = $this->createStub( TranslatableDescription::class ); $translatableDescriptionMock->method( 'getText' )->willReturn( 'Ich spende einmalig' ); - $urlGenerator = new CreditCard( - CreditCardConfig::newFromConfig( + $urlGenerator = new CreditCardURLGenerator( + CreditCardURLGeneratorConfig::newFromConfig( [ 'base-url' => 'https://credit-card.micropayment.de/creditcard/event/index.php?', 'project-id' => 'wikimedia', @@ -79,20 +79,19 @@ public function testWhenTestModeIsEnabled_urlPassesProperParameter(): void { ], $translatableDescriptionMock ), + new FakeUrlAuthenticator(), new CreditCardPayment( 32, Euro::newFromCents( 100 ), PaymentInterval::OneTime ) ); - $requestContext = new RequestContext( + $requestContext = new DomainSpecificContext( itemId: 1234567, - updateToken: "my_update_token", - accessToken: "my_access_token", firstName: "Kai", lastName: "Nissen", ); $this->assertSame( 'https://credit-card.micropayment.de/creditcard/event/index.php?project=wikimedia&bgcolor=CCE7CD&' . 'paytext=Ich+spende+einmalig&mp_user_firstname=Kai&mp_user_surname=Nissen&sid=1234567&gfx=wikimedia_black&' . - 'token=my_access_token&utoken=my_update_token&amount=100&theme=wikimedia&producttype=fee&lang=de&testmode=1', + 'amount=100&theme=wikimedia&producttype=fee&lang=de&token=p-test-token-0&utoken=p-test-token-1&testmode=1', $urlGenerator->generateUrl( $requestContext ) ); } @@ -105,7 +104,7 @@ public static function donationProvider(): array { [ 'https://credit-card.micropayment.de/creditcard/event/index.php?project=wikimedia&bgcolor=CCE7CD&' . 'paytext=Ich+spende+einmalig&mp_user_firstname=Kai&mp_user_surname=Nissen&sid=1234567&gfx=wikimedia_black&' . - 'token=my_access_token&utoken=my_update_token&amount=500&theme=wikimedia&producttype=fee&lang=de', + 'amount=500&theme=wikimedia&producttype=fee&lang=de&token=p-test-token-0&utoken=p-test-token-1', 'Kai', 'Nissen', 'Ich spende einmalig', @@ -119,7 +118,7 @@ public static function donationProvider(): array { [ 'https://credit-card.micropayment.de/creditcard/event/index.php?project=wikimedia&bgcolor=CCE7CD&' . 'paytext=Ich+spende+monatlich&mp_user_firstname=Kai&mp_user_surname=Nissen&sid=1234567&gfx=wikimedia_black&' . - 'token=my_access_token&utoken=my_update_token&amount=123&theme=wikimedia&producttype=fee&lang=de', + 'amount=123&theme=wikimedia&producttype=fee&lang=de&token=p-test-token-0&utoken=p-test-token-1', 'Kai', 'Nissen', 'Ich spende monatlich', @@ -133,8 +132,7 @@ public static function donationProvider(): array { [ 'https://credit-card.micropayment.de/creditcard/event/index.php?project=wikimedia&bgcolor=CCE7CD&' . 'paytext=Ich+spende+halbj%C3%A4hrlich&mp_user_firstname=Kai&mp_user_surname=Nissen&sid=1234567&' . - 'gfx=wikimedia_black&token=my_access_token&utoken=my_update_token&amount=1250&theme=wikimedia&' . - 'producttype=fee&lang=de', + 'gfx=wikimedia_black&amount=1250&theme=wikimedia&producttype=fee&lang=de&token=p-test-token-0&utoken=p-test-token-1', 'Kai', 'Nissen', 'Ich spende halbjährlich', diff --git a/tests/Unit/Services/PaymentUrlGenerator/IncompletePayPalURLGeneratorTest.php b/tests/Unit/Services/PaymentUrlGenerator/IncompletePayPalURLGeneratorTest.php new file mode 100644 index 00000000..ed33e2a0 --- /dev/null +++ b/tests/Unit/Services/PaymentUrlGenerator/IncompletePayPalURLGeneratorTest.php @@ -0,0 +1,23 @@ +expectException( \LogicException::class ); + $this->expectExceptionMessageMatches( '/instance should be replaced/' ); + $generator->generateURL( new DomainSpecificContext( 5 ) ); + } +} diff --git a/tests/Unit/Domain/PaymentUrlGenerator/PayPalTest.php b/tests/Unit/Services/PaymentUrlGenerator/LegacyPayPalURLGeneratorTest.php similarity index 74% rename from tests/Unit/Domain/PaymentUrlGenerator/PayPalTest.php rename to tests/Unit/Services/PaymentUrlGenerator/LegacyPayPalURLGeneratorTest.php index 2d072a47..541feeb0 100644 --- a/tests/Unit/Domain/PaymentUrlGenerator/PayPalTest.php +++ b/tests/Unit/Services/PaymentUrlGenerator/LegacyPayPalURLGeneratorTest.php @@ -2,22 +2,25 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Tests\Unit\Domain\PaymentUrlGenerator; +namespace Unit\Services\PaymentUrlGenerator; use PHPUnit\Framework\TestCase; use WMDE\Euro\Euro; use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentInterval; use WMDE\Fundraising\PaymentContext\Domain\Model\PayPalPayment; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\PayPal as PaypalUrlGenerator; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\PayPalConfig; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\RequestContext; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\TranslatableDescription; +use WMDE\Fundraising\PaymentContext\Domain\UrlGenerator\DomainSpecificContext; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\LegacyPayPalURLGenerator; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\LegacyPayPalURLGeneratorConfig; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\TranslatableDescription; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FakeLegacyPayPalURLGeneratorConfig; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FakeUrlAuthenticator; /** - * @covers \WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\PayPal + * @covers \WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\LegacyPayPalURLGenerator + * @covers \WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\LegacyPayPalURLGeneratorConfig * */ -class PayPalTest extends TestCase { +class LegacyPayPalURLGeneratorTest extends TestCase { private const BASE_URL = 'https://www.sandbox.paypal.com/cgi-bin/webscr?'; private const LOCALE = 'de_DE'; @@ -25,12 +28,12 @@ class PayPalTest extends TestCase { private const NOTIFY_URL = 'https://my.donation.app/handler/paypal/'; private const RETURN_URL = 'https://my.donation.app/donation/confirm/'; private const CANCEL_URL = 'https://my.donation.app/donation/cancel/'; - private const ITEM_NAME = 'Mentioning that awesome organization on the invoice'; - private RequestContext $testRequestContext; + private DomainSpecificContext $testRequestContext; public function setup(): void { - $this->testRequestContext = new RequestContext( + $this->testRequestContext = new DomainSpecificContext( 1234, + null, 'd1234', 'utoken', 'atoken' @@ -43,7 +46,10 @@ public function testSubscriptions(): void { Euro::newFromString( '12.34' ), PaymentInterval::Quarterly ); - $generator = new PayPalUrlGenerator( $this->newPayPalUrlConfig(), $payment ); + $generator = new LegacyPayPalURLGenerator( + FakeLegacyPayPalURLGeneratorConfig::create(), + new FakeUrlAuthenticator(), $payment + ); $this->assertUrlValidForSubscriptions( $generator->generateUrl( $this->testRequestContext ) @@ -61,7 +67,7 @@ public function testSinglePayments(): void { Euro::newFromString( '12.34' ), PaymentInterval::OneTime ); - $generator = new PayPalUrlGenerator( $this->newPayPalUrlConfig(), $payment ); + $generator = new LegacyPayPalURLGenerator( FakeLegacyPayPalURLGeneratorConfig::create(), new FakeUrlAuthenticator(), $payment ); $this->assertUrlValidForSinglePayments( $generator->generateUrl( $this->testRequestContext ) @@ -73,30 +79,14 @@ private function assertUrlValidForSinglePayments( string $generatedUrl ): void { $this->assertSinglePaymentRelatedParamsSet( $generatedUrl ); } - private function newPayPalUrlConfig(): PayPalConfig { - $descriptionStub = $this->createStub( TranslatableDescription::class ); - $descriptionStub->method( 'getText' )->willReturn( self::ITEM_NAME ); - return PayPalConfig::newFromConfig( - [ - 'base-url' => self::BASE_URL, - 'locale' => self::LOCALE, - 'account-address' => self::ACCOUNT_ADDRESS, - 'notify-url' => self::NOTIFY_URL, - 'return-url' => self::RETURN_URL, - 'cancel-url' => self::CANCEL_URL - ], - $descriptionStub - ); - } - public function testGivenIncompletePayPalUrlConfig_exceptionIsThrown(): void { $this->expectException( \RuntimeException::class ); $this->newIncompletePayPalUrlConfig(); } - private function newIncompletePayPalUrlConfig(): PayPalConfig { + private function newIncompletePayPalUrlConfig(): LegacyPayPalURLGeneratorConfig { $descriptionStub = $this->createStub( TranslatableDescription::class ); - return PayPalConfig::newFromConfig( + return LegacyPayPalURLGeneratorConfig::newFromConfig( [ 'base-url' => self::BASE_URL, 'locale' => self::LOCALE, @@ -116,7 +106,7 @@ public function testDelayedSubscriptions(): void { PaymentInterval::Quarterly ); - $generator = new PayPalUrlGenerator( $this->newPayPalUrlConfigWithDelayedPayment(), $payment ); + $generator = new LegacyPayPalURLGenerator( $this->newPayPalUrlConfigWithDelayedPayment(), new FakeUrlAuthenticator(), $payment ); $this->assertUrlValidForDelayedSubscriptions( $generator->generateUrl( $this->testRequestContext ) @@ -129,10 +119,10 @@ private function assertUrlValidForDelayedSubscriptions( string $generatedUrl ): $this->assertTrialPeriodRelatedParametersSet( $generatedUrl ); } - private function newPayPalUrlConfigWithDelayedPayment(): PayPalConfig { + private function newPayPalUrlConfigWithDelayedPayment(): LegacyPayPalURLGeneratorConfig { $descriptionStub = $this->createStub( TranslatableDescription::class ); $descriptionStub->method( 'getText' )->willReturn( 'Mentioning that awesome organization on the invoice' ); - return PayPalConfig::newFromConfig( + return LegacyPayPalURLGeneratorConfig::newFromConfig( [ 'base-url' => self::BASE_URL, 'locale' => self::LOCALE, @@ -157,10 +147,10 @@ private function assertCommonParamsSet( string $generatedUrl ): void { $this->assertStringContainsString( 'notify_url=https%3A%2F%2Fmy.donation.app%2Fhandler%2Fpaypal%2F', $generatedUrl ); $this->assertStringContainsString( 'cancel_return=https%3A%2F%2Fmy.donation.app%2Fdonation%2Fcancel%2F', $generatedUrl ); $this->assertStringContainsString( - 'return=https%3A%2F%2Fmy.donation.app%2Fdonation%2Fconfirm%2F%3Fid%3D1234%26accessToken%3Datoken', + 'return=https%3A%2F%2Fmy.donation.app%2Fdonation%2Fconfirm%2F%3Fid%3D1234%26testAccessToken%3DLET_ME_IN', $generatedUrl ); - $this->assertStringContainsString( 'custom=%7B%22sid%22%3A1234%2C%22utoken%22%3A%22utoken%22%7D', $generatedUrl ); + $this->assertStringContainsString( 'custom=p-test-token-0', $generatedUrl ); } private function assertSinglePaymentRelatedParamsSet( string $generatedUrl ): void { diff --git a/tests/Unit/Services/PaymentUrlGenerator/PayPalURLGeneratorConfigTest.php b/tests/Unit/Services/PaymentUrlGenerator/PayPalURLGeneratorConfigTest.php new file mode 100644 index 00000000..b6b9d3c1 --- /dev/null +++ b/tests/Unit/Services/PaymentUrlGenerator/PayPalURLGeneratorConfigTest.php @@ -0,0 +1,53 @@ +expectException( \RuntimeException::class ); + $this->newIncompletePayPalUrlConfig(); + } + + private function newIncompletePayPalUrlConfig(): LegacyPayPalURLGeneratorConfig { + return LegacyPayPalURLGeneratorConfig::newFromConfig( + [ + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_BASE_URL => 'http://that.paymentprovider.com/?', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_LOCALE => 'de_DE', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_ACCOUNT_ADDRESS => 'some@email-adress.com', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_NOTIFY_URL => 'http://my.donation.app/handler/paypal/', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_RETURN_URL => 'http://my.donation.app/donation/confirm/', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_CANCEL_URL => '', + ], + $this->createMock( TranslatableDescription::class ) + ); + } + + public function testGivenValidPayPalUrlConfig_payPalConfigIsReturned(): void { + $this->assertInstanceOf( LegacyPayPalURLGeneratorConfig::class, $this->newPayPalUrlConfig() ); + } + + private function newPayPalUrlConfig(): LegacyPayPalURLGeneratorConfig { + return LegacyPayPalURLGeneratorConfig::newFromConfig( + [ + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_BASE_URL => 'http://that.paymentprovider.com/?', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_LOCALE => 'de_DE', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_ACCOUNT_ADDRESS => 'some@email-adress.com', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_NOTIFY_URL => 'http://my.donation.app/handler/paypal/', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_RETURN_URL => 'http://my.donation.app/donation/confirm/', + LegacyPayPalURLGeneratorConfig::CONFIG_KEY_CANCEL_URL => 'http://my.donation.app/donation/cancel/', + ], + $this->createMock( TranslatableDescription::class ) + ); + } + +} diff --git a/tests/Unit/Services/PaymentUrlGenerator/PayPalURLGeneratorTest.php b/tests/Unit/Services/PaymentUrlGenerator/PayPalURLGeneratorTest.php new file mode 100644 index 00000000..39a0f369 --- /dev/null +++ b/tests/Unit/Services/PaymentUrlGenerator/PayPalURLGeneratorTest.php @@ -0,0 +1,19 @@ +assertSame( $url, $generator->generateURL( new DomainSpecificContext( 5 ) ) ); + } +} diff --git a/tests/Unit/Domain/PaymentUrlGenerator/Sofort/RequestTest.php b/tests/Unit/Services/PaymentUrlGenerator/Sofort/RequestTest.php similarity index 78% rename from tests/Unit/Domain/PaymentUrlGenerator/Sofort/RequestTest.php rename to tests/Unit/Services/PaymentUrlGenerator/Sofort/RequestTest.php index a4bab03b..01aaf2f5 100644 --- a/tests/Unit/Domain/PaymentUrlGenerator/Sofort/RequestTest.php +++ b/tests/Unit/Services/PaymentUrlGenerator/Sofort/RequestTest.php @@ -2,14 +2,14 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Tests\Unit\Domain\PaymentUrlGenerator\Sofort; +namespace Unit\Services\PaymentUrlGenerator\Sofort; use PHPUnit\Framework\TestCase; use WMDE\Euro\Euro; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Request; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Request; /** - * @covers \WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Request + * @covers \WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Request */ class RequestTest extends TestCase { diff --git a/tests/Unit/Domain/PaymentUrlGenerator/Sofort/ResponseTest.php b/tests/Unit/Services/PaymentUrlGenerator/Sofort/ResponseTest.php similarity index 67% rename from tests/Unit/Domain/PaymentUrlGenerator/Sofort/ResponseTest.php rename to tests/Unit/Services/PaymentUrlGenerator/Sofort/ResponseTest.php index e81e44ca..9d17f8ce 100644 --- a/tests/Unit/Domain/PaymentUrlGenerator/Sofort/ResponseTest.php +++ b/tests/Unit/Services/PaymentUrlGenerator/Sofort/ResponseTest.php @@ -2,13 +2,13 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Tests\Unit\Domain\PaymentUrlGenerator\Sofort; +namespace Unit\Services\PaymentUrlGenerator\Sofort; use PHPUnit\Framework\TestCase; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Response; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Response; /** - * @covers \WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Response + * @covers \WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Response */ class ResponseTest extends TestCase { diff --git a/tests/Unit/Domain/PaymentUrlGenerator/SofortTest.php b/tests/Unit/Services/PaymentUrlGenerator/SofortURLGeneratorTest.php similarity index 61% rename from tests/Unit/Domain/PaymentUrlGenerator/SofortTest.php rename to tests/Unit/Services/PaymentUrlGenerator/SofortURLGeneratorTest.php index 004cc835..9ab8de73 100644 --- a/tests/Unit/Domain/PaymentUrlGenerator/SofortTest.php +++ b/tests/Unit/Services/PaymentUrlGenerator/SofortURLGeneratorTest.php @@ -2,7 +2,7 @@ declare( strict_types = 1 ); -namespace WMDE\Fundraising\PaymentContext\Tests\Unit\Domain\PaymentUrlGenerator; +namespace Unit\Services\PaymentUrlGenerator; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -10,28 +10,27 @@ use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentInterval; use WMDE\Fundraising\PaymentContext\Domain\Model\PaymentReferenceCode; use WMDE\Fundraising\PaymentContext\Domain\Model\SofortPayment; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\RequestContext; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort as SofortUrlGenerator; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\SofortConfig as SofortUrlConfig; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\TranslatableDescription; +use WMDE\Fundraising\PaymentContext\Domain\UrlGenerator\DomainSpecificContext; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\SofortURLGenerator; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\SofortURLGeneratorConfig; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\TranslatableDescription; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\ExceptionThrowingSofortSofortClient; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FakeUrlAuthenticator; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\SofortSofortClientSpy; /** - * @covers \WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort + * @covers \WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\SofortURLGenerator */ -class SofortTest extends TestCase { +class SofortURLGeneratorTest extends TestCase { public function testSofortUrlGeneratorPassesValuesInRequestToClient(): void { $internalItemId = 44; $externalItemId = 'wx529836'; $amount = Euro::newFromCents( 600 ); - $updateToken = 'UDtoken'; - $accessToken = 'XStoken'; $locale = 'DE'; $translatableDescription = $this->createMock( TranslatableDescription::class ); - $config = new SofortUrlConfig( + $config = new SofortURLGeneratorConfig( $locale, 'https://us.org/yes', 'https://us.org/no', @@ -45,20 +44,17 @@ public function testSofortUrlGeneratorPassesValuesInRequestToClient(): void { PaymentInterval::OneTime, $this->createMock( PaymentReferenceCode::class ) ); - $urlGenerator = new SofortUrlGenerator( $config, $client, $payment ); + $urlGenerator = new SofortURLGenerator( $config, $client, new FakeUrlAuthenticator(), $payment ); - $requestContext = new RequestContext( + $requestContext = new DomainSpecificContext( $internalItemId, - $externalItemId, - $updateToken, - $accessToken + null, + $externalItemId ); $urlGenerator->generateUrl( $requestContext ); - $this->assertStringContainsString( "id=$internalItemId", $client->request->getSuccessUrl() ); - $this->assertStringContainsString( "id=$internalItemId", $client->request->getNotificationUrl() ); - $this->assertStringContainsString( "accessToken=$accessToken", $client->request->getSuccessUrl() ); - $this->assertStringContainsString( "updateToken=$updateToken", $client->request->getNotificationUrl() ); + $this->assertStringContainsString( "testAccessToken=LET_ME_IN", $client->request->getSuccessUrl() ); + $this->assertStringContainsString( "testAccessToken=LET_ME_IN", $client->request->getNotificationUrl() ); $this->assertSame( $amount, $client->request->getAmount() ); $this->assertSame( $locale, $client->request->getLocale() ); } @@ -66,7 +62,7 @@ public function testSofortUrlGeneratorPassesValuesInRequestToClient(): void { public function testSofortUrlGeneratorReturnsUrlFromClient(): void { $expectedUrl = 'https://dn.ht/picklecat/'; $translatableDescriptionMock = $this->createMock( TranslatableDescription::class ); - $config = new SofortUrlConfig( + $config = new SofortURLGeneratorConfig( 'DE', 'https://us.org/yes', 'https://us.org/no', @@ -81,13 +77,13 @@ public function testSofortUrlGeneratorReturnsUrlFromClient(): void { PaymentInterval::OneTime, $this->createMock( PaymentReferenceCode::class ) ); - $urlGenerator = new SofortUrlGenerator( $config, $client, $payment ); + $urlGenerator = new SofortURLGenerator( $config, $client, new FakeUrlAuthenticator(), $payment ); - $requestContext = new RequestContext( + $requestContext = new DomainSpecificContext( 44, + null, 'wx529836', - 'up date token :)', - 'ax ess token :)' ); + ); $returnedUrl = $urlGenerator->generateUrl( $requestContext ); $this->assertSame( $expectedUrl, $returnedUrl ); @@ -95,7 +91,7 @@ public function testSofortUrlGeneratorReturnsUrlFromClient(): void { public function testWhenApiReturnsErrorAnExceptionWithApiErrorMessageIsThrown(): void { $translatableDescriptionStub = $this->createStub( TranslatableDescription::class ); - $config = new SofortUrlConfig( + $config = new SofortURLGeneratorConfig( 'DE', 'https://irreleva.nt/y', 'https://irreleva.nt/n', @@ -107,19 +103,15 @@ public function testWhenApiReturnsErrorAnExceptionWithApiErrorMessageIsThrown(): 23, Euro::newFromCents( 600 ), PaymentInterval::OneTime, - $this->createMock( PaymentReferenceCode::class ) ); + $this->createMock( PaymentReferenceCode::class ) + ); - $urlGenerator = new SofortUrlGenerator( $config, $client, $payment ); + $urlGenerator = new SofortURLGenerator( $config, $client, new FakeUrlAuthenticator(), $payment ); $this->expectException( RuntimeException::class ); $this->expectExceptionMessage( 'Could not generate Sofort URL: boo boo' ); - $requestContext = new RequestContext( - itemId: 23, - updateToken: 'token_to_updateblabla', - accessToken: 'token_to_accessblabla' - - ); + $requestContext = new DomainSpecificContext( itemId: 23 ); $urlGenerator->generateUrl( $requestContext ); } } diff --git a/tests/Unit/Services/SofortLibClientTest.php b/tests/Unit/Services/SofortLibClientTest.php index b7f4cf0f..d5634886 100644 --- a/tests/Unit/Services/SofortLibClientTest.php +++ b/tests/Unit/Services/SofortLibClientTest.php @@ -8,7 +8,7 @@ use RuntimeException; use Sofort\SofortLib\Sofortueberweisung; use WMDE\Euro\Euro; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\Sofort\Request; +use WMDE\Fundraising\PaymentContext\Services\PaymentUrlGenerator\Sofort\Request; use WMDE\Fundraising\PaymentContext\Services\SofortLibClient; /** diff --git a/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseBuilder.php b/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseBuilder.php index 8d783812..d8d22a33 100644 --- a/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseBuilder.php +++ b/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseBuilder.php @@ -11,14 +11,18 @@ use WMDE\Fundraising\PaymentContext\Domain\PaymentIdRepository; use WMDE\Fundraising\PaymentContext\Domain\PaymentReferenceCodeGenerator; use WMDE\Fundraising\PaymentContext\Domain\PaymentRepository; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\NullGenerator; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\UrlGeneratorFactory; use WMDE\Fundraising\PaymentContext\Domain\PaymentValidator; use WMDE\Fundraising\PaymentContext\Services\KontoCheck\KontoCheckBankDataGenerator; +use WMDE\Fundraising\PaymentContext\Services\URLAuthenticator; +use WMDE\Fundraising\PaymentContext\Services\UrlGeneratorFactory; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FixedPaymentReferenceCodeGenerator; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\PaymentCompletionURLGeneratorStub; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\PaymentRepositorySpy; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\SucceedingIbanValidator; use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\CreatePaymentUseCase; +use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\DefaultPaymentProviderAdapter; +use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentProviderAdapter; +use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentProviderAdapterFactory; use WMDE\Fundraising\PaymentContext\UseCases\ValidateIban\ValidateIbanUseCase; class CreatePaymentUseCaseBuilder { @@ -28,6 +32,7 @@ class CreatePaymentUseCaseBuilder { private UrlGeneratorFactory $urlGeneratorFactory; private ValidateIbanUseCase $validateIbanUseCase; private PaymentValidator $paymentValidator; + private PaymentProviderAdapterFactory $paymentProviderAdapterFactory; public function __construct() { $this->idGenerator = $this->makeIdGeneratorStub(); @@ -36,6 +41,7 @@ public function __construct() { $this->urlGeneratorFactory = $this->makePaymentURLFactoryStub(); $this->validateIbanUseCase = $this->makeFailingIbanUseCase(); $this->paymentValidator = $this->makePaymentValidator(); + $this->paymentProviderAdapterFactory = $this->makePaymentProviderAdapterFactory(); } public function build(): CreatePaymentUseCase { @@ -45,7 +51,8 @@ public function build(): CreatePaymentUseCase { $this->paymentReferenceCodeGenerator, $this->paymentValidator, $this->validateIbanUseCase, - $this->urlGeneratorFactory + $this->urlGeneratorFactory, + $this->paymentProviderAdapterFactory ); } @@ -75,8 +82,8 @@ private function makePaymentReferenceGeneratorStub(): PaymentReferenceCodeGenera private function makePaymentURLFactoryStub(): UrlGeneratorFactory { return new class implements UrlGeneratorFactory { - public function createURLGenerator( Payment $payment ): NullGenerator { - return new NullGenerator(); + public function createURLGenerator( Payment $payment, URLAuthenticator $authenticator ): PaymentCompletionURLGeneratorStub { + return new PaymentCompletionURLGeneratorStub(); } }; } @@ -138,4 +145,25 @@ public function getBankDataFromIban( Iban $iban ): ExtendedBankData { }; } + + private function makePaymentProviderAdapterFactory(): PaymentProviderAdapterFactory { + return new class implements PaymentProviderAdapterFactory { + public function createProvider( Payment $payment, URLAuthenticator $authenticator ): PaymentProviderAdapter { + return new DefaultPaymentProviderAdapter(); + } + }; + } + + public function withPaymentProviderAdapter( PaymentProviderAdapter $paymentProviderAdapter ): self { + $this->paymentProviderAdapterFactory = new class( $paymentProviderAdapter ) implements PaymentProviderAdapterFactory { + public function __construct( private readonly PaymentProviderAdapter $paymentProviderAdapter ) { + } + + public function createProvider( Payment $payment, URLAuthenticator $authenticator ): PaymentProviderAdapter { + return $this->paymentProviderAdapter; + } + }; + return $this; + } + } diff --git a/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseTest.php b/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseTest.php index d8b4083e..893c0bc4 100644 --- a/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseTest.php +++ b/tests/Unit/UseCases/CreatePayment/CreatePaymentUseCaseTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use WMDE\Euro\Euro; +use WMDE\Fundraising\PaymentContext\Domain\DomainSpecificPaymentValidator; use WMDE\Fundraising\PaymentContext\Domain\Model\BankTransferPayment; use WMDE\Fundraising\PaymentContext\Domain\Model\CreditCardPayment; use WMDE\Fundraising\PaymentContext\Domain\Model\DirectDebitPayment; @@ -15,14 +16,18 @@ use WMDE\Fundraising\PaymentContext\Domain\Model\PayPalPayment; use WMDE\Fundraising\PaymentContext\Domain\Model\SofortPayment; use WMDE\Fundraising\PaymentContext\Domain\PaymentReferenceCodeGenerator; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\PaymentProviderURLGenerator; -use WMDE\Fundraising\PaymentContext\Domain\PaymentUrlGenerator\UrlGeneratorFactory; +use WMDE\Fundraising\PaymentContext\Domain\UrlGenerator\PaymentCompletionURLGenerator; +use WMDE\Fundraising\PaymentContext\Services\UrlGeneratorFactory; use WMDE\Fundraising\PaymentContext\Tests\Data\DirectDebitBankData; +use WMDE\Fundraising\PaymentContext\Tests\Data\DomainSpecificContextForTesting; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FailingDomainSpecificValidator; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FakeUrlAuthenticator; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\SequentialPaymentIdRepository; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\SucceedingDomainSpecificValidator; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\UrlGeneratorStub; use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\FailureResponse; use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentCreationRequest; +use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentProviderAdapter; use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\SuccessResponse; /** @@ -226,8 +231,8 @@ public function testCreatePaymentWithFailingDomainValidationFails(): void { amountInEuroCents: 500, interval: 0, paymentType: 'PPL', + domainSpecificPaymentValidator: new FailingDomainSpecificValidator() ); - $request->setDomainSpecificPaymentValidator( new FailingDomainSpecificValidator() ); $result = $useCase->createPayment( $request ); $this->assertInstanceOf( FailureResponse::class, $result ); @@ -251,10 +256,9 @@ public function testCreateDirectDebitPaymentWithInvalidIbanFails(): void { $this->assertEquals( "An invalid IBAN was provided", $result->errorMessage ); } - public function testPaymentResponseContainsURLGeneratorFromFactory(): void { - $urlGenerator = $this->createStub( PaymentProviderURLGenerator::class ); + public function testPaymentResponseContainsURLFromURLGeneratorFactory(): void { $urlGeneratorFactory = $this->createStub( UrlGeneratorFactory::class ); - $urlGeneratorFactory->method( 'createURLGenerator' )->willReturn( $urlGenerator ); + $urlGeneratorFactory->method( 'createURLGenerator' )->willReturn( new UrlGeneratorStub() ); $useCase = $this->useCaseBuilder ->withIdGenerator( new SequentialPaymentIdRepository( self::PAYMENT_ID ) ) ->withPaymentRepositorySpy() @@ -264,11 +268,59 @@ public function testPaymentResponseContainsURLGeneratorFromFactory(): void { $result = $useCase->createPayment( $this->newPaymentCreationRequest( amountInEuroCents: 100, interval: 0, - paymentType: 'PPL', + paymentType: 'MCP', + ) ); + + $this->assertInstanceOf( SuccessResponse::class, $result ); + $this->assertSame( UrlGeneratorStub::URL, $result->paymentCompletionUrl ); + } + + public function testPaymentProviderAdapterCanReplaceUrlGenerator(): void { + $urlGeneratorFactory = $this->givenUrlGeneratorFactoryReturnsIncompleteUrlGenerator(); + $adapterStub = $this->createStub( PaymentProviderAdapter::class ); + $adapterStub->method( 'modifyPaymentUrlGenerator' )->willReturn( new UrlGeneratorStub() ); + $useCase = $this->useCaseBuilder + ->withIdGenerator( new SequentialPaymentIdRepository( self::PAYMENT_ID ) ) + ->withPaymentRepositorySpy() + ->withUrlGeneratorFactory( $urlGeneratorFactory ) + ->withPaymentProviderAdapter( $adapterStub ) + ->build(); + + $result = $useCase->createPayment( $this->newPaymentCreationRequest( + amountInEuroCents: 100, + interval: 0, + paymentType: 'MCP', + ) ); + + $this->assertInstanceOf( SuccessResponse::class, $result ); + $this->assertSame( UrlGeneratorStub::URL, $result->paymentCompletionUrl ); + } + + public function testPaymentProviderCanReplacePaymentBeforeStoring(): void { + $replacementPaymentId = 123; + $paymentFromAdapter = new CreditCardPayment( $replacementPaymentId, Euro::newFromCents( 789 ), PaymentInterval::OneTime ); + $adapter = $this->createMock( PaymentProviderAdapter::class ); + $adapter->expects( $this->once() ) + ->method( 'fetchAndStoreAdditionalData' ) + ->willReturn( $paymentFromAdapter ); + + $useCase = $this->useCaseBuilder + ->withIdGenerator( new SequentialPaymentIdRepository( self::PAYMENT_ID ) ) + ->withPaymentRepositorySpy() + ->withPaymentProviderAdapter( $adapter ) + ->build(); + + $result = $useCase->createPayment( $this->newPaymentCreationRequest( + amountInEuroCents: 100, + interval: 0, + paymentType: 'MCP', ) ); $this->assertInstanceOf( SuccessResponse::class, $result ); - $this->assertSame( $urlGenerator, $result->paymentProviderURLGenerator ); + $this->assertSame( $replacementPaymentId, $result->paymentId ); + $repositorySpy = $this->useCaseBuilder->getPaymentRepository(); + $storedPayment = $repositorySpy->getPaymentById( $replacementPaymentId ); + $this->assertSame( $paymentFromAdapter, $storedPayment ); } private function newPaymentCreationRequest( @@ -277,13 +329,20 @@ private function newPaymentCreationRequest( string $paymentType, string $iban = '', string $bic = '', - string $transferCodePrefix = '' + string $transferCodePrefix = '', + ?DomainSpecificPaymentValidator $domainSpecificPaymentValidator = null ): PaymentCreationRequest { - $request = new PaymentCreationRequest( - $amountInEuroCents, $interval, $paymentType, $iban, $bic, $transferCodePrefix + return new PaymentCreationRequest( + $amountInEuroCents, + $interval, + $paymentType, + $domainSpecificPaymentValidator ?? new SucceedingDomainSpecificValidator(), + DomainSpecificContextForTesting::create(), + new FakeUrlAuthenticator(), + $iban, + $bic, + $transferCodePrefix ); - $request->setDomainSpecificPaymentValidator( new SucceedingDomainSpecificValidator() ); - return $request; } private function makePaymentReferenceGenerator(): PaymentReferenceCodeGenerator { @@ -299,4 +358,13 @@ private function assertPaymentWasStored( Payment $expectedPayment ): void { $actualPayment = $this->useCaseBuilder->getPaymentRepository()->getPaymentById( self::PAYMENT_ID ); $this->assertEquals( $expectedPayment, $actualPayment ); } + + private function givenUrlGeneratorFactoryReturnsIncompleteUrlGenerator(): UrlGeneratorFactory { + $urlGenerator = $this->createStub( PaymentCompletionURLGenerator::class ); + $urlGenerator->method( 'generateURL' ) + ->willThrowException( new \LogicException( 'The "original" URL generator should be replaced by the payment provider adapter' ) ); + $urlGeneratorFactory = $this->createStub( UrlGeneratorFactory::class ); + $urlGeneratorFactory->method( 'createURLGenerator' )->willReturn( $urlGenerator ); + return $urlGeneratorFactory; + } } diff --git a/tests/Unit/UseCases/CreatePayment/DefaultPaymentProviderAdapterTest.php b/tests/Unit/UseCases/CreatePayment/DefaultPaymentProviderAdapterTest.php new file mode 100644 index 00000000..ff5f5976 --- /dev/null +++ b/tests/Unit/UseCases/CreatePayment/DefaultPaymentProviderAdapterTest.php @@ -0,0 +1,48 @@ +givenCreditCardURLGenerator( $payment ); + $context = DomainSpecificContextForTesting::create(); + + $this->assertSame( $payment, $adapter->fetchAndStoreAdditionalData( $payment, $context ) ); + $this->assertSame( $urlGenerator, $adapter->modifyPaymentUrlGenerator( $urlGenerator, $context ) ); + } + + private function givenCreditCardURLGenerator( CreditCardPayment $payment ): CreditCardURLGenerator { + return new CreditCardURLGenerator( + CreditCardURLGeneratorConfig::newFromConfig( + [ + 'base-url' => 'https://credit-card.micropayment.de/creditcard/event/index.php?', + 'project-id' => 'wikimedia', + 'locale' => 'de', + 'background-color' => 'CCE7CD', + 'logo' => 'wikimedia_black', + 'theme' => 'wikimedia', + 'testmode' => false + ], + $this->createMock( TranslatableDescription::class ) + ), + new FakeUrlAuthenticator(), + $payment ); + } +} diff --git a/tests/Unit/UseCases/CreatePayment/PaymentCreationRequestTest.php b/tests/Unit/UseCases/CreatePayment/PaymentCreationRequestTest.php index b49a4495..5ca29f48 100644 --- a/tests/Unit/UseCases/CreatePayment/PaymentCreationRequestTest.php +++ b/tests/Unit/UseCases/CreatePayment/PaymentCreationRequestTest.php @@ -4,37 +4,69 @@ namespace WMDE\Fundraising\PaymentContext\Tests\Unit\UseCases\CreatePayment; use PHPUnit\Framework\TestCase; +use WMDE\Fundraising\PaymentContext\Tests\Data\DomainSpecificContextForTesting; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FailingDomainSpecificValidator; +use WMDE\Fundraising\PaymentContext\Tests\Fixtures\FakeUrlAuthenticator; use WMDE\Fundraising\PaymentContext\Tests\Fixtures\SucceedingDomainSpecificValidator; use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentCreationRequest; +use WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentParameters; /** * @covers \WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentCreationRequest + * @covers \WMDE\Fundraising\PaymentContext\UseCases\CreatePayment\PaymentParameters */ class PaymentCreationRequestTest extends TestCase { public function testRequestCanBeStringified(): void { - $request = new PaymentCreationRequest( 9876, 1, 'BEZ', 'DE88100900001234567892', 'BEVODEBB', 'D' ); - $request->setDomainSpecificPaymentValidator( new SucceedingDomainSpecificValidator() ); + $request = new PaymentCreationRequest( + 9876, + 1, + 'BEZ', + new SucceedingDomainSpecificValidator(), + DomainSpecificContextForTesting::create(), + new FakeUrlAuthenticator(), + 'DE88100900001234567892', + 'BEVODEBB', + 'D' + ); $this->assertSame( - '{"domainSpecificPaymentValidator":"WMDE\\\\Fundraising\\\\PaymentContext\\\\Tests\\\\Fixtures\\\\SucceedingDomainSpecificValidator","amountInEuroCents":9876,"interval":1,"paymentType":"BEZ","iban":"DE88100900001234567892","bic":"BEVODEBB","transferCodePrefix":"D"}', + '{"amountInEuroCents":9876,"interval":1,"paymentType":"BEZ","domainSpecificPaymentValidator":"WMDE\\\\Fundraising\\\\PaymentContext\\\\Tests\\\\Fixtures\\\\SucceedingDomainSpecificValidator","domainSpecificContext":{"itemId":1,"startTimeForRecurringPayment":null,"invoiceId":"D-1","firstName":"Hubert J.","lastName":"Farnsworth"},"iban":"DE88100900001234567892","bic":"BEVODEBB","transferCodePrefix":"D"}', (string)$request ); } public function testGivenInvalidInputStringifiedOutputIsErrorMessage(): void { - $request = new PaymentCreationRequest( 9876, 1, 'BEZ', "\xB1\x31", ); - $request->setDomainSpecificPaymentValidator( new SucceedingDomainSpecificValidator() ); + $request = new PaymentCreationRequest( + 9876, + 1, + 'BEZ', + new SucceedingDomainSpecificValidator(), + DomainSpecificContextForTesting::create(), + new FakeUrlAuthenticator(), + "\xB1\x31", + ); - $this->assertSame( - 'JSON encode error in WMDE\\Fundraising\\PaymentContext\\UseCases\\CreatePayment\\PaymentCreationRequest::__toString: Malformed UTF-8 characters, possibly incorrectly encoded', - (string)$request + $requestAsString = (string)$request; + + $this->assertStringContainsString( 'JSON encode error', $requestAsString ); + $this->assertStringContainsString( + '::__toString: Malformed UTF-8 characters, possibly incorrectly encoded', + $requestAsString ); } public function testJSONRepresentationContainsValidatorClassName(): void { - $request = new PaymentCreationRequest( 9876, 1, 'BEZ', 'DE88100900001234567892', 'BEVODEBB', 'D' ); - $request->setDomainSpecificPaymentValidator( new FailingDomainSpecificValidator() ); + $request = new PaymentCreationRequest( + 9876, + 1, + 'BEZ', + new FailingDomainSpecificValidator(), + DomainSpecificContextForTesting::create(), + new FakeUrlAuthenticator(), + 'DE88100900001234567892', + 'BEVODEBB', + 'D' + ); $json = $request->jsonSerialize(); @@ -45,4 +77,24 @@ public function testJSONRepresentationContainsValidatorClassName(): void { $json->domainSpecificPaymentValidator ); } + + public function testPaymentParametersRoundTrip(): void { + $parameters = new PaymentParameters( + 9876, + 1, + 'BEZ', + 'DE88100900001234567892', + 'BEVODEBB', + 'D' + ); + $request = PaymentCreationRequest::newFromParameters( + $parameters, + new SucceedingDomainSpecificValidator(), + DomainSpecificContextForTesting::create(), + new FakeUrlAuthenticator(), + ); + + $this->assertEquals( $parameters, $request->getParameters() ); + } + } diff --git a/tests/Unit/UseCases/CreateSubscriptionPlansForProduct/CreateSubscriptionPlansForProductTest.php b/tests/Unit/UseCases/CreateSubscriptionPlansForProduct/CreateSubscriptionPlansForProductTest.php new file mode 100644 index 00000000..1820ed3e --- /dev/null +++ b/tests/Unit/UseCases/CreateSubscriptionPlansForProduct/CreateSubscriptionPlansForProductTest.php @@ -0,0 +1,159 @@ +create( new CreateSubscriptionPlanRequest( '', '', PaymentInterval::HalfYearly, 'blabla' ) ); + + $this->assertInstanceOf( ErrorResult::class, $result ); + $this->assertSame( 'Name and Id must not be empty', $result->message ); + } + + public function testPassingEmptySubscriptionPlanReturnsErrorResult(): void { + $useCase = new CreateSubscriptionPlanForProductUseCase( new FakePayPalAPIForSetup() ); + + $result = $useCase->create( new CreateSubscriptionPlanRequest( 'bla', 'blabla', PaymentInterval::HalfYearly, '' ) ); + + $this->assertInstanceOf( ErrorResult::class, $result ); + $this->assertSame( 'Subscription plan name must not be empty', $result->message ); + } + + public function testReturnsErrorResultWhenListingProductIsNotSuccessful(): void { + $api = $this->createStub( PaypalAPI::class ); + $api->method( 'listProducts' )->willThrowException( new PayPalAPIException( 'Listing products not allowed' ) ); + $useCase = new CreateSubscriptionPlanForProductUseCase( $api ); + + $result = $useCase->create( new CreateSubscriptionPlanRequest( 'ProductId-1', 'ProductName-1', PaymentInterval::HalfYearly, 'Half-Yearly Payment' ) ); + + $this->assertInstanceOf( ErrorResult::class, $result ); + $this->assertSame( 'Listing products not allowed', $result->message ); + } + + public function testReturnsErrorResultWhenCreatingProductIsNotSuccessful(): void { + $api = $this->createStub( PaypalAPI::class ); + $api->method( 'createProduct' )->willThrowException( new PayPalAPIException( 'Failed to create product' ) ); + $useCase = new CreateSubscriptionPlanForProductUseCase( $api ); + + $result = $useCase->create( new CreateSubscriptionPlanRequest( 'ProductId-2', 'ProductName-2', PaymentInterval::HalfYearly, 'Half-Yearly Payment' ) ); + + $this->assertInstanceOf( ErrorResult::class, $result ); + $this->assertSame( 'Failed to create product', $result->message ); + } + + /** + * @covers \WMDE\Fundraising\PaymentContext\UseCases\CreateSubscriptionPlansForProduct\CreateSubscriptionPlanRequest + */ + public function testThrowsExceptionWhenRequestedWithOneTimePaymentInterval(): void { + $this->expectException( \UnexpectedValueException::class ); + new CreateSubscriptionPlanRequest( '', '', PaymentInterval::OneTime, 'One-Time Payment' ); + } + + /** + * @return iterable + */ + public static function apiDataProvider(): iterable { + yield 'no pre-existing product and plan' => [ [] , [], false, false ]; + yield 'product already exists, create new plan' => [ [ self::createProduct( "id1" ) ], [], true, false ]; + yield 'different product exists, create new product and plan' => [ [ self::createProduct( 'id42' ) ], [], false, false ]; + yield 'different product and with a plan exists, create new product and plan' => [ + [ self::createProduct( 'id666' ) ], + [ new SubscriptionPlan( 'Half-Yearly payment for product', 'id666', PaymentInterval::HalfYearly, self::SUBSCRIPTION_PLAN_ID ) ], + false, + false + ]; + yield 'product already existed, with a different plan, create new plan for it' => [ + [ self::createProduct( 'id1' ) ], + [ new SubscriptionPlan( 'Monthly payment for product', 'id1', PaymentInterval::Monthly, self::SUBSCRIPTION_PLAN_ID ) ], + true, + false + ]; + } + + private static function createProduct( string $id ): Product { + return new Product( $id, 'P1', '' ); + } + + /** + * @dataProvider apiDataProvider + * @param Product[] $products + * @param SubscriptionPlan[] $subscriptionPlans + */ + public function testFetchesOrCreatesNewProductsAndPlansAndGivesSuccessResult( array $products, array $subscriptionPlans, bool $productExists, bool $subscriptionPlanExists ): void { + $product = self::createProduct( "id1" ); + $expectedSubscriptionPlan = new SubscriptionPlan( 'A test plan', 'id1', PaymentInterval::HalfYearly, FakePayPalAPIForSetup::GENERATED_ID ); + + $api = new FakePayPalAPIForSetup( $products, $subscriptionPlans ); + $useCase = new CreateSubscriptionPlanForProductUseCase( $api ); + + $result = $useCase->create( new CreateSubscriptionPlanRequest( + $product->id, + $product->name, + PaymentInterval::HalfYearly, + 'A test plan' + ) + ); + + $expectedResult = new SuccessResult( + $product, + $productExists, + $expectedSubscriptionPlan, + $subscriptionPlanExists + ); + $this->assertEquals( $expectedResult, $result ); + $this->assertTrue( $api->hasProduct( $product ) ); + $this->assertTrue( $api->hasSubscriptionPlan( $expectedSubscriptionPlan ) ); + } + + public function testThrowsErrorWhenCreatingPlanWasNotSuccessful(): void { + $api = $this->createStub( PaypalAPI::class ); + $api->method( 'createSubscriptionPlanForProduct' )->willThrowException( new PayPalAPIException( 'Creation of subscription plan failed' ) ); + $useCase = new CreateSubscriptionPlanForProductUseCase( $api ); + + $result = $useCase->create( new CreateSubscriptionPlanRequest( + 'ProductId-3', + 'ProductName-3', + PaymentInterval::HalfYearly, + 'Half-Yearly Payment' + ) ); + + $this->assertInstanceOf( ErrorResult::class, $result ); + $this->assertSame( 'Creation of subscription plan failed', $result->message ); + } + + public function testThrowsErrorWhenListingSubscriptionPlanWasNotSuccessful(): void { + $api = $this->createStub( PaypalAPI::class ); + $api->method( 'listSubscriptionPlansForProduct' )->willThrowException( new PayPalAPIException( 'Listing of subscription plan failed' ) ); + $useCase = new CreateSubscriptionPlanForProductUseCase( $api ); + + $result = $useCase->create( new CreateSubscriptionPlanRequest( + 'ProductId-4', + 'ProductName-4', + PaymentInterval::HalfYearly, + 'Half-Yearly Payment' + ) ); + + $this->assertInstanceOf( ErrorResult::class, $result ); + $this->assertSame( 'Listing of subscription plan failed', $result->message ); + } +}