From d909ceec2cef87f8d1bb0fc8e62b14e1409b1c99 Mon Sep 17 00:00:00 2001 From: Sam Poyigi <6567634+sampoyigi@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:46:18 +0000 Subject: [PATCH] Remove obsolete test case and add new test cases for payment methods. Signed-off-by: Sam Poyigi <6567634+sampoyigi@users.noreply.github.com> --- composer.json | 4 +- phpunit.xml.dist | 3 + src/Classes/AuthorizeNetClient.php | 22 +- src/Classes/BasePaymentGateway.php | 10 +- src/Concerns/WithApplicableFee.php | 4 +- src/Database/Factories/PaymentFactory.php | 5 +- src/Database/Factories/PaymentLogFactory.php | 23 + .../Factories/PaymentProfileFactory.php | 23 + src/Extension.php | 6 +- src/FormWidgets/PaymentAttempts.php | 14 +- src/Http/Controllers/Payments.php | 19 +- src/Models/PaymentLog.php | 5 +- src/Models/PaymentProfile.php | 3 + src/Payments/AuthorizeNetAim.php | 31 +- src/Payments/Mollie.php | 34 +- src/Payments/PaypalExpress.php | 55 +-- src/Payments/Square.php | 8 +- src/Payments/Stripe.php | 45 +- tests/Classes/AuthorizeNetClientTest.php | 87 ++++ tests/Classes/BasePaymentGatewayTest.php | 110 +++++ tests/Classes/PayPalClientTest.php | 128 +++++ tests/Classes/PaymentGatewaysTest.php | 83 ++++ tests/Concerns/WithApplicableFeeTest.php | 118 +++++ tests/Concerns/WithAuthorizedPaymentTest.php | 71 +++ tests/Concerns/WithPaymentProfileTest.php | 48 ++ tests/Concerns/WithPaymentRefundTest.php | 37 ++ tests/ExampleTest.php | 5 - tests/ExtensionTest.php | 131 +++++ tests/FormWidgets/PaymentAttemptsTest.php | 102 ++++ tests/Http/Controllers/PaymentsTest.php | 183 +++++++ .../CaptureAuthorizedPaymentTest.php | 65 +++ ...datePaymentIntentSessionOnCheckoutTest.php | 44 ++ tests/Models/PaymentLogTest.php | 89 ++++ tests/Models/PaymentProfileTest.php | 80 +++ tests/Models/PaymentTest.php | 184 +++++++ tests/Payments/AuthorizeNetAimTest.php | 310 ++++++++++++ tests/Payments/CodTest.php | 45 ++ tests/Payments/Fixtures/TestPayment.php | 25 + .../Fixtures/TestPaymentWithAuthorized.php | 32 ++ .../Fixtures/TestPaymentWithNoRefund.php | 22 + .../Fixtures/TestPaymentWithRefund.php | 28 ++ tests/Payments/MollieTest.php | 242 +++++++++ tests/Payments/PaypalExpressTest.php | 227 +++++++++ tests/Payments/SquareTest.php | 343 +++++++++++++ tests/Payments/StripeTest.php | 462 ++++++++++++++++++ tests/Pest.php | 9 + .../Subscribers/FormFieldsSubscriberTest.php | 45 ++ tests/TestCase.php | 14 - tests/_fixtures/fields.php | 20 + 49 files changed, 3544 insertions(+), 159 deletions(-) create mode 100644 src/Database/Factories/PaymentLogFactory.php create mode 100644 src/Database/Factories/PaymentProfileFactory.php create mode 100644 tests/Classes/AuthorizeNetClientTest.php create mode 100644 tests/Classes/BasePaymentGatewayTest.php create mode 100644 tests/Classes/PayPalClientTest.php create mode 100644 tests/Classes/PaymentGatewaysTest.php create mode 100644 tests/Concerns/WithApplicableFeeTest.php create mode 100644 tests/Concerns/WithAuthorizedPaymentTest.php create mode 100644 tests/Concerns/WithPaymentProfileTest.php create mode 100644 tests/Concerns/WithPaymentRefundTest.php delete mode 100644 tests/ExampleTest.php create mode 100644 tests/ExtensionTest.php create mode 100644 tests/FormWidgets/PaymentAttemptsTest.php create mode 100644 tests/Http/Controllers/PaymentsTest.php create mode 100644 tests/Listeners/CaptureAuthorizedPaymentTest.php create mode 100644 tests/Listeners/UpdatePaymentIntentSessionOnCheckoutTest.php create mode 100644 tests/Models/PaymentLogTest.php create mode 100644 tests/Models/PaymentProfileTest.php create mode 100644 tests/Models/PaymentTest.php create mode 100644 tests/Payments/AuthorizeNetAimTest.php create mode 100644 tests/Payments/CodTest.php create mode 100644 tests/Payments/Fixtures/TestPayment.php create mode 100644 tests/Payments/Fixtures/TestPaymentWithAuthorized.php create mode 100644 tests/Payments/Fixtures/TestPaymentWithNoRefund.php create mode 100644 tests/Payments/Fixtures/TestPaymentWithRefund.php create mode 100644 tests/Payments/MollieTest.php create mode 100644 tests/Payments/PaypalExpressTest.php create mode 100644 tests/Payments/SquareTest.php create mode 100644 tests/Payments/StripeTest.php create mode 100644 tests/Subscribers/FormFieldsSubscriberTest.php delete mode 100644 tests/TestCase.php create mode 100644 tests/_fixtures/fields.php diff --git a/composer.json b/composer.json index 123d42d..d291378 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "mollie" ], "require": { - "tastyigniter/core": "^v4.0@beta", + "tastyigniter/core": "^v4.0@beta || ^v4.0@dev", "php-http/guzzle7-adapter": "~1.0", "authorizenet/authorizenet": "2.0.2", "stripe/stripe-php": "~7.93.0", @@ -69,5 +69,5 @@ }, "sort-packages": true }, - "minimum-stability": "beta" + "minimum-stability": "dev" } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9eb3e47..34cf9a4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,9 @@ + + + diff --git a/src/Classes/AuthorizeNetClient.php b/src/Classes/AuthorizeNetClient.php index 5879253..a8c6c04 100644 --- a/src/Classes/AuthorizeNetClient.php +++ b/src/Classes/AuthorizeNetClient.php @@ -38,10 +38,8 @@ public function createTransactionRequest(): CreateTransactionRequest return $this->transactionRequest = $request; } - public function createTransaction(?CreateTransactionRequest $request): TransactionResponseType + public function createTransaction(CreateTransactionController $controller): TransactionResponseType { - $controller = new CreateTransactionController($request); - $response = $controller->executeWithApiResponse($this->sandbox ? \net\authorize\api\constants\ANetEnvironment::SANDBOX : \net\authorize\api\constants\ANetEnvironment::PRODUCTION); @@ -50,15 +48,13 @@ public function createTransaction(?CreateTransactionRequest $request): Transacti $transactionResponse = $response->getTransactionResponse(); - throw_unless( - $response->getMessages()->getResultCode() == 'Ok', - new ApplicationException($this->getErrorMessageFromResponse($response, $transactionResponse)) - ); + if ($response->getMessages()->getResultCode() !== 'Ok') { + throw new ApplicationException($this->getErrorMessageFromResponse($response, $transactionResponse)); + } - throw_if( - is_null($transactionResponse) || is_null($transactionResponse->getMessages()), - new ApplicationException($this->getErrorMessageFromResponse($response, $transactionResponse)) - ); + if (is_null($transactionResponse) || is_null($transactionResponse->getMessages())) { + throw new ApplicationException($this->getErrorMessageFromResponse($response, $transactionResponse)); + } return $transactionResponse; } @@ -69,13 +65,13 @@ protected function getErrorMessageFromResponse(?AnetApiResponseType $response, ? if ($transactionResponse != null && $transactionResponse->getErrors() != null) { return sprintf($message, $transactionResponse->getErrors()[0]->getErrorCode(), - $transactionResponse->getErrors()[0]->getErrorText() + $transactionResponse->getErrors()[0]->getErrorText(), ); } return sprintf($message, $response->getMessages()->getMessage()[0]->getCode(), - $response->getMessages()->getMessage()[0]->getText() + $response->getMessages()->getMessage()[0]->getText(), ); } } diff --git a/src/Classes/BasePaymentGateway.php b/src/Classes/BasePaymentGateway.php index 2ca423f..6c1cea9 100644 --- a/src/Classes/BasePaymentGateway.php +++ b/src/Classes/BasePaymentGateway.php @@ -160,12 +160,18 @@ protected function validatePaymentMethod($order, $host) * @param Model $host Type model object containing configuration fields values. * @param Model $order Order model object. */ - public function processPaymentForm($data, $host, $order) {} + public function processPaymentForm($data, $host, $order) + { + throw new \LogicException('Method processPaymentForm must be implemented on your custom payment class.'); + } /** * Executed when this gateway is rendered on the checkout page. */ - public function beforeRenderPaymentForm($host, $controller) {} + public function beforeRenderPaymentForm($host, $controller) + { + throw new \LogicException('Method beforeRenderPaymentForm must be implemented on your custom payment class.'); + } public function getPaymentFormViewName() { diff --git a/src/Concerns/WithApplicableFee.php b/src/Concerns/WithApplicableFee.php index 39d5cf6..48c0b9d 100644 --- a/src/Concerns/WithApplicableFee.php +++ b/src/Concerns/WithApplicableFee.php @@ -20,7 +20,7 @@ protected function validateApplicableFee(Order $order, ?Payment $host = null) if (!$this->isApplicable($order->order_total, $host)) { throw new ApplicationException(sprintf( lang('igniter.payregister::default.alert_min_order_total'), - currency_format($host->order_total), $host->name + currency_format($host->order_total), $host->name, )); } } @@ -56,4 +56,4 @@ public function getFormattedApplicableFee(?Payment $host = null): string ? $host->order_fee.'%' : currency_format($host->order_fee); } -} \ No newline at end of file +} diff --git a/src/Database/Factories/PaymentFactory.php b/src/Database/Factories/PaymentFactory.php index ddd9cfa..d78ac38 100644 --- a/src/Database/Factories/PaymentFactory.php +++ b/src/Database/Factories/PaymentFactory.php @@ -3,6 +3,7 @@ namespace Igniter\PayRegister\Database\Factories; use Igniter\Flame\Database\Factories\Factory; +use Igniter\PayRegister\Tests\Payments\Fixtures\TestPayment; class PaymentFactory extends Factory { @@ -12,8 +13,8 @@ public function definition(): array { return [ 'name' => $this->faker->name, - 'code' => $this->faker->name, - 'class_name' => $this->faker->name, + 'code' => $this->faker->word, + 'class_name' => TestPayment::class, 'description' => $this->faker->text, 'data' => [], 'priority' => $this->faker->randomNumber(), diff --git a/src/Database/Factories/PaymentLogFactory.php b/src/Database/Factories/PaymentLogFactory.php new file mode 100644 index 0000000..e176ada --- /dev/null +++ b/src/Database/Factories/PaymentLogFactory.php @@ -0,0 +1,23 @@ + $this->faker->sentence, + 'payment_code' => $this->faker->word, + 'payment_name' => $this->faker->name, + 'is_success' => true, + 'request' => [], + 'response' => [], + 'is_refundable' => false, + ]; + } +} diff --git a/src/Database/Factories/PaymentProfileFactory.php b/src/Database/Factories/PaymentProfileFactory.php new file mode 100644 index 0000000..257e351 --- /dev/null +++ b/src/Database/Factories/PaymentProfileFactory.php @@ -0,0 +1,23 @@ + 1, + 'payment_id' => 1, + 'card_brand' => 1, + 'card_last4' => 1, + 'profile_data' => [], + 'is_primary' => false, + ]; + } +} diff --git a/src/Extension.php b/src/Extension.php index 98fb33f..bf1699a 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -14,6 +14,10 @@ class Extension extends BaseExtension { + protected $subscribe = [ + FormFieldsSubscriber::class, + ]; + protected $listen = [ 'igniter.checkout.afterSaveOrder' => [ UpdatePaymentIntentSessionOnCheckout::class, @@ -117,8 +121,6 @@ public function registerOnboardingSteps() public function boot() { - Event::subscribe(FormFieldsSubscriber::class); - Event::listen('main.theme.activated', function() { Payment::syncAll(); }); diff --git a/src/FormWidgets/PaymentAttempts.php b/src/FormWidgets/PaymentAttempts.php index bc1cd71..c50ec01 100644 --- a/src/FormWidgets/PaymentAttempts.php +++ b/src/FormWidgets/PaymentAttempts.php @@ -50,11 +50,9 @@ public function getSaveValue(mixed $value): int public function onLoadRecord() { - $paymentLogId = post('recordId'); + $paymentLogId = input('recordId'); - throw_unless($model = PaymentLog::find($paymentLogId), - new FlashException('Record not found') - ); + throw_unless($model = PaymentLog::find($paymentLogId), new FlashException('Record not found')); $formTitle = sprintf(lang($this->formTitle), currency_format($model->order->order_total)); @@ -67,19 +65,21 @@ public function onLoadRecord() public function onSaveRecord() { - $paymentLog = PaymentLog::find(post('recordId')); + $paymentLogId = input('recordId'); + + throw_unless($paymentLog = PaymentLog::find($paymentLogId), new FlashException('Record not found')); $paymentMethod = $this->model->payment_method; throw_unless( $paymentLog && $paymentMethod->canRefundPayment($paymentLog), - new FlashException('No successful payment to refund') + new FlashException('No successful payment to refund'), ); $widget = $this->makeRefundFormWidget($paymentLog); $data = $widget->getSaveData(); - $this->validate($data, $widget->config['rules']); + $this->validate($data, $widget->config['rules'] ?? []); $paymentMethod->processRefundForm($data, $this->model, $paymentLog); diff --git a/src/Http/Controllers/Payments.php b/src/Http/Controllers/Payments.php index 585733c..bd77076 100644 --- a/src/Http/Controllers/Payments.php +++ b/src/Http/Controllers/Payments.php @@ -104,7 +104,7 @@ public function listOverrideColumnValue($record, $column, $alias = null) public function formFindModelObject($paymentCode = null) { throw_unless(strlen($paymentCode), - new FlashException(lang('igniter.payregister::default.alert_setting_missing_id')) + new FlashException(lang('igniter.payregister::default.alert_setting_missing_id')), ); $model = $this->formCreateModelObject(); @@ -115,25 +115,12 @@ public function formFindModelObject($paymentCode = null) $this->formExtendQuery($query); throw_unless($result = $query->whereCode($paymentCode)->first(), - new FlashException(lang('igniter::admin.form.not_found')) + new FlashException(lang('igniter::admin.form.not_found')), ); return $this->formExtendModel($result) ?: $result; } - protected function getGateway($code) - { - if ($this->gateway !== null) { - return $this->gateway; - } - - throw_unless($gateway = resolve(PaymentGateways::class)->findGateway($code), - new FlashException(sprintf(lang('igniter.payregister::default.alert_code_not_found'), $code)) - ); - - return $this->gateway = $gateway; - } - public function formExtendModel($model) { if (!$model->exists) { @@ -158,7 +145,7 @@ public function formExtendFieldsBefore($form) public function formBeforeCreate($model) { throw_unless(strlen($code = post('Payment.payment')), - new FlashException(lang('igniter.payregister::default.alert_invalid_code')) + new FlashException(lang('igniter.payregister::default.alert_invalid_code')), ); $paymentGateway = resolve(PaymentGateways::class)->findGateway($code); diff --git a/src/Models/PaymentLog.php b/src/Models/PaymentLog.php index abc6cfd..890af32 100644 --- a/src/Models/PaymentLog.php +++ b/src/Models/PaymentLog.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Igniter\Cart\Events\OrderBeforeRefundProcessedEvent; use Igniter\Cart\Events\OrderRefundProcessedEvent; +use Igniter\Flame\Database\Factories\HasFactory; use Igniter\Flame\Database\Model; use Igniter\Flame\Database\Traits\Validation; @@ -13,6 +14,7 @@ */ class PaymentLog extends Model { + use HasFactory; use Validation; /** @@ -29,8 +31,6 @@ class PaymentLog extends Model public $timestamps = true; - public $dates = ['refunded_at']; - public $relation = [ 'belongsTo' => [ 'order' => [\Igniter\Cart\Models\Order::class], @@ -55,6 +55,7 @@ class PaymentLog extends Model 'response' => 'array', 'is_success' => 'boolean', 'is_refundable' => 'boolean', + 'refunded_at' => 'datetime', ]; public static function logAttempt($order, $message, $isSuccess, $request = [], $response = [], $isRefundable = false) diff --git a/src/Models/PaymentProfile.php b/src/Models/PaymentProfile.php index d9e051d..67c21bc 100644 --- a/src/Models/PaymentProfile.php +++ b/src/Models/PaymentProfile.php @@ -2,10 +2,13 @@ namespace Igniter\PayRegister\Models; +use Igniter\Flame\Database\Factories\HasFactory; use Igniter\Flame\Database\Model; class PaymentProfile extends Model { + use HasFactory; + public $timestamps = true; public $table = 'payment_profiles'; diff --git a/src/Payments/AuthorizeNetAim.php b/src/Payments/AuthorizeNetAim.php index 86a731b..2a6e711 100644 --- a/src/Payments/AuthorizeNetAim.php +++ b/src/Payments/AuthorizeNetAim.php @@ -14,6 +14,7 @@ use net\authorize\api\contract\v1\PaymentType; use net\authorize\api\contract\v1\TransactionRequestType; use net\authorize\api\contract\v1\TransactionResponseType; +use net\authorize\api\controller\CreateTransactionController; class AuthorizeNetAim extends BasePaymentGateway { @@ -118,21 +119,21 @@ public function processPaymentForm($data, $host, $order) } } catch (Exception $ex) { $order->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, $fields); - } - throw new ApplicationException('Sorry, there was an error processing your payment. Please try again later.'); + throw new ApplicationException('Sorry, there was an error processing your payment. Please try again later.'); + } } public function processRefundForm($data, $order, $paymentLog) { throw_if( !is_null($paymentLog->refunded_at) || !is_array($paymentLog->response), - new ApplicationException('Nothing to refund') + new ApplicationException('Nothing to refund'), ); throw_if( array_get($paymentLog->response, 'status') !== '1', - new ApplicationException('No successful transaction to refund') + new ApplicationException('No successful transaction to refund'), ); $paymentId = array_get($paymentLog->response, 'id'); @@ -145,7 +146,7 @@ public function processRefundForm($data, $order, $paymentLog) $responseData = $this->convertResponseToArray($response); $order->logPaymentAttempt(sprintf('Payment %s refund processed -> (%s: %s)', - $paymentId, array_get($data, 'refund_type'), $responseData['id'] + $paymentId, array_get($data, 'refund_type'), $responseData['id'], ), 1, $fields, $responseData); $paymentLog->markAsRefundProcessed(); @@ -160,12 +161,12 @@ public function captureAuthorizedPayment(Order $order) { throw_unless( $paymentLog = $order->payment_logs()->firstWhere('is_success', true), - new ApplicationException('No successful transaction to capture') + new ApplicationException('No successful transaction to capture'), ); throw_unless( $paymentId = array_get($paymentLog->response, 'id'), - new ApplicationException('Missing payment ID in successful transaction response') + new ApplicationException('Missing payment ID in successful transaction response'), ); $transactionRequestType = new TransactionRequestType; @@ -180,7 +181,7 @@ public function captureAuthorizedPayment(Order $order) $this->fireSystemEvent('payregister.authorizenetaim.extendCaptureRequest', [$request], false); - $response = $client->createTransaction($request); + $response = $client->createTransaction(new CreateTransactionController($request)); $responseData = $this->convertResponseToArray($response); if ($response->getResponseCode() == '1') { @@ -196,12 +197,12 @@ public function cancelAuthorizedPayment(Order $order) { throw_unless( $paymentLog = $order->payment_logs()->firstWhere('is_success', true), - new ApplicationException('No successful transaction to capture') + new ApplicationException('No successful transaction to capture'), ); throw_unless( $paymentId = array_get($paymentLog->response, 'id'), - new ApplicationException('Missing payment ID in successful transaction response') + new ApplicationException('Missing payment ID in successful transaction response'), ); $transactionRequestType = new TransactionRequestType; @@ -215,7 +216,7 @@ public function cancelAuthorizedPayment(Order $order) $this->fireSystemEvent('payregister.authorizenetaim.extendCancelRequest', [$request], false); - $response = $client->createTransaction($request); + $response = $client->createTransaction(new CreateTransactionController($request)); $responseData = $this->convertResponseToArray($response); if ($response->getResponseCode() == '1') { @@ -259,7 +260,7 @@ protected function getPaymentRefundFields($order, $data) ? $order->order_total : array_get($data, 'refund_amount'); throw_if($refundAmount > $order->order_total, new ApplicationException( - 'Refund amount should be be less than or equal to the order total' + 'Refund amount should be be less than or equal to the order total', )); return [ @@ -276,7 +277,7 @@ protected function createAcceptPayment(mixed $fields, Order $order) $transactionRequestType = new TransactionRequestType; $transactionRequestType->setTransactionType( - $this->shouldAuthorizePayment() ? 'authOnlyTransaction' : 'authCaptureTransaction' + $this->shouldAuthorizePayment() ? 'authOnlyTransaction' : 'authCaptureTransaction', ); $transactionRequestType->setAmount($fields['amount']); @@ -292,7 +293,7 @@ protected function createAcceptPayment(mixed $fields, Order $order) $this->fireSystemEvent('payregister.authorizenetaim.extendAcceptRequest', [$request], false); - return $client->createTransaction($request); + return $client->createTransaction(new CreateTransactionController($request)); } protected function createRefundPayment(mixed $fields, Order $order) @@ -317,7 +318,7 @@ protected function createRefundPayment(mixed $fields, Order $order) $this->fireSystemEvent('payregister.authorizenetaim.extendRefundRequest', [$request], false); - return $client->createTransaction($request); + return $client->createTransaction(new CreateTransactionController($request)); } protected function convertResponseToArray(TransactionResponseType $response): array diff --git a/src/Payments/Mollie.php b/src/Payments/Mollie.php index f565127..151e701 100644 --- a/src/Payments/Mollie.php +++ b/src/Payments/Mollie.php @@ -76,33 +76,31 @@ public function processPaymentForm($data, $host, $order) 'amount' => $payment->amount, ]); } catch (Exception $ex) { - $order->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, $fields, []); - - throw new ApplicationException('Sorry, there was an error processing your payment. Please try again later.'); + $order->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, $fields); } + + throw new ApplicationException('Sorry, there was an error processing your payment. Please try again later.'); } public function processReturnUrl($params) { $hash = $params[0] ?? null; - $redirectPage = input('redirect'); - $cancelPage = input('cancel'); + $redirectPage = input('redirect') ?: 'checkout.checkout'; + $cancelPage = input('cancel') ?: 'checkout.checkout'; $order = $this->createOrderModel()->whereHash($hash)->first(); try { throw_unless($order, new ApplicationException('No order found')); - throw_unless(strlen($redirectPage), new ApplicationException('No redirect page found')); - throw_unless(strlen($cancelPage), new ApplicationException('No cancel page found')); throw_if( - !($paymentMethod = $order->payment_method) || $paymentMethod->getGatewayClass() != static::class, - new ApplicationException('No valid payment method found') + !($paymentMethod = $order->payment_method) || !$paymentMethod->getGatewayObject() instanceof Mollie, + new ApplicationException('No valid payment method found'), ); throw_unless( $payment = $this->createClient()->payments->get(session()->get('mollie.payment_id')), - new ApplicationException('Missing payment id in query parameters') + new ApplicationException('Missing payment id in query parameters'), ); throw_if($order->isPaymentProcessed(), new ApplicationException('Payment has already been processed')); @@ -123,7 +121,7 @@ public function processReturnUrl($params) 'hash' => $order->hash, ])); } catch (Exception $ex) { - $order->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, [], request()->input()); + $order?->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, [], request()->input()); flash()->warning($ex->getMessage())->important(); } @@ -138,13 +136,13 @@ public function processNotifyUrl($params) throw_unless($order, new ApplicationException('No order found')); throw_if( - !($paymentMethod = $order->payment_method) || $paymentMethod->getGatewayClass() != static::class, - new ApplicationException('No valid payment method found') + !($paymentMethod = $order->payment_method) || !$paymentMethod->getGatewayObject() instanceof Mollie, + new ApplicationException('No valid payment method found'), ); throw_unless( $payment = $this->createClient()->payments->get(request()->input('id', '')), - new ApplicationException('Missing payment id in query parameters') + new ApplicationException('Payment not found or missing payment id in query parameters'), ); $response = [ @@ -172,12 +170,12 @@ public function processRefundForm($data, $order, $paymentLog) { throw_if( !is_null($paymentLog->refunded_at) || !is_array($paymentLog->response), - new ApplicationException('Nothing to refund') + new ApplicationException('Nothing to refund'), ); throw_if( array_get($paymentLog->response, 'status') !== 'paid', - new ApplicationException('No charge to refund') + new ApplicationException('No charge to refund'), ); $paymentId = array_get($paymentLog->response, 'id'); @@ -188,7 +186,7 @@ public function processRefundForm($data, $order, $paymentLog) $response = $payment->refund($fields); $message = sprintf('Payment %s refund processed -> (%s: %s)', - $paymentId, array_get($data, 'refund_type'), $response->id + $paymentId, array_get($data, 'refund_type'), $response->id, ); $order->logPaymentAttempt($message, 1, $fields, [ @@ -301,7 +299,7 @@ protected function getPaymentRefundFields($order, $data) ? $order->order_total : array_get($data, 'refund_amount'); throw_if($refundAmount > $order->order_total, new ApplicationException( - 'Refund amount should be be less than or equal to the order total' + 'Refund amount should be be less than or equal to the order total', )); $fields = [ diff --git a/src/Payments/PaypalExpress.php b/src/Payments/PaypalExpress.php index eaa02fc..4e6d287 100644 --- a/src/Payments/PaypalExpress.php +++ b/src/Payments/PaypalExpress.php @@ -43,6 +43,11 @@ public function getApiPassword() return $this->isSandboxMode() ? $this->model->api_sandbox_pass : $this->model->api_pass; } + public function getTransactionMode(): string + { + return $this->model->api_action === 'authorization' ? 'AUTHORIZE' : 'CAPTURE'; + } + /** * @param array $data * @param \Igniter\PayRegister\Models\Payment $host @@ -76,30 +81,28 @@ public function processPaymentForm($data, $host, $order) public function processReturnUrl($params) { $hash = $params[0] ?? null; - $redirectPage = input('redirect'); - $cancelPage = input('cancel'); + $redirectPage = input('redirect') ?: 'checkout.checkout'; + $cancelPage = input('cancel') ?: 'checkout.checkout'; $order = $this->createOrderModel()->whereHash($hash)->first(); try { throw_unless($order, new ApplicationException('No order found')); - throw_unless(strlen($redirectPage), new ApplicationException('No redirect page found')); - throw_unless(strlen($cancelPage), new ApplicationException('No cancel page found')); throw_if( - !($paymentMethod = $order->payment_method) || $paymentMethod->getGatewayClass() != static::class, - new ApplicationException('No valid payment method found') + !($paymentMethod = $order->payment_method) || !$paymentMethod->getGatewayObject() instanceof PaypalExpress, + new ApplicationException('No valid payment method found'), ); throw_unless( strlen($token = request()->input('token', '')), - new ApplicationException('Missing valid token in response') + new ApplicationException('Missing valid token in response'), ); $response = $this->createClient()->getOrder($token); throw_if( array_get($response, 'status') !== 'APPROVED', - new ApplicationException('Payment is not approved') + new ApplicationException('Payment is not approved'), ); if (array_get($response, 'intent') === 'CAPTURE') { @@ -125,7 +128,7 @@ public function processReturnUrl($params) 'hash' => $order->hash, ])); } catch (Exception $ex) { - $order->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, [], $ex->getTrace()); + $order?->logPaymentAttempt('Payment error -> '.$ex->getMessage(), 0, [], $ex->getTrace()); flash()->warning($ex->getMessage())->important(); } @@ -135,19 +138,16 @@ public function processReturnUrl($params) public function processCancelUrl($params) { $hash = $params[0] ?? null; - throw_unless( - $order = $this->createOrderModel()->whereHash($hash)->first(), - new ApplicationException('No order found') - ); + $redirectPage = input('redirect') ?: 'checkout.checkout'; throw_unless( - strlen($redirectPage = request()->input('redirect')), - new ApplicationException('No redirect page found') + $order = $this->createOrderModel()->whereHash($hash)->first(), + new ApplicationException('No order found'), ); throw_if( - !($paymentMethod = $order->payment_method) || $paymentMethod->getGatewayClass() != static::class, - new ApplicationException('No valid payment method found') + !($paymentMethod = $order->payment_method) || !$paymentMethod->getGatewayObject() instanceof PaypalExpress, + new ApplicationException('No valid payment method found'), ); $order->logPaymentAttempt('Payment canceled by customer', 0, [], request()->input()); @@ -159,12 +159,12 @@ public function processRefundForm($data, $order, $paymentLog) { throw_if( !is_null($paymentLog->refunded_at) || !is_array($paymentLog->response), - new ApplicationException('Nothing to refund') + new ApplicationException('Nothing to refund'), ); throw_if( array_get($paymentLog->response, 'purchase_units.0.payments.captures.0.status') !== 'COMPLETED', - new ApplicationException('No charge to refund') + new ApplicationException('No charge to refund'), ); $paymentId = array_get($paymentLog->response, 'purchase_units.0.payments.captures.0.id'); @@ -174,7 +174,7 @@ public function processRefundForm($data, $order, $paymentLog) $response = $this->createClient()->refundPayment($paymentId, $fields); $message = sprintf('Payment %s refunded successfully -> (%s: %s)', - $paymentId, array_get($data, 'refund_type'), $response->json('id') + $paymentId, array_get($data, 'refund_type'), $response->json('id'), ); $order->logPaymentAttempt($message, 1, $fields, $response->json()); @@ -204,7 +204,7 @@ protected function getPaymentFormFields($order, $data = []) $returnUrl .= '?redirect='.array_get($data, 'successPage').'&cancel='.array_get($data, 'cancelPage'); $fields = [ - 'intent' => $this->model->api_action === 'authorization' ? 'AUTHORIZE' : 'CAPTURE', + 'intent' => $this->getTransactionMode(), 'application_context' => [ 'brand_name' => setting('site_name'), ], @@ -225,17 +225,6 @@ protected function getPaymentFormFields($order, $data = []) $fields['purchase_units'][] = [ 'reference_id' => $order->hash, 'custom_id' => $order->getKey(), - // 'items' => $order->getOrderMenus()->map(function(OrderMenu $orderMenu) use ($currencyCode) { - // return [ - // 'name' => $orderMenu->name, - // 'quantity' => $orderMenu->quantity, - // 'unit_amount' => [ - // 'currency_code' => $currencyCode, - // 'value' => number_format($orderMenu->price, 2, '.', ''), - // ], - // 'category' => 'PHYSICAL_GOODS', - // ]; - // })->all(), 'amount' => [ 'currency_code' => $currencyCode, 'value' => number_format($order->order_total, 2, '.', ''), @@ -253,7 +242,7 @@ protected function getPaymentRefundFields($order, $data) ? array_get($data, 'refund_amount') : $order->order_total; throw_if($refundAmount > $order->order_total, new ApplicationException( - 'Refund amount should be be less than or equal to the order total' + 'Refund amount should be be less than or equal to the order total', )); $fields = [ diff --git a/src/Payments/Square.php b/src/Payments/Square.php index 4fa1eca..a557b0d 100644 --- a/src/Payments/Square.php +++ b/src/Payments/Square.php @@ -337,7 +337,7 @@ public function processRefundForm($data, $order, $paymentLog) $message = sprintf('Payment %s refunded successfully -> (%s: %s)', $paymentChargeId, array_get($data, 'refund_type'), - array_get($response->getResult(), 'id') + array_get($response->getResult(), 'id'), ); $order->logPaymentAttempt($message, 1, $fields, $response->getResult()); @@ -354,7 +354,7 @@ protected function getPaymentRefundFields($order, $data) ? array_get($data, 'refund_amount') : $order->order_total; throw_if($refundAmount > $order->order_total, new ApplicationException( - 'Refund amount should be be less than or equal to the order total' + 'Refund amount should be be less than or equal to the order total', )); $fields = [ @@ -438,7 +438,7 @@ protected function handlePaymentResponse(ApiResponse $response, Order $order, Pa protected function handleUpdatePaymentProfile($customer, $data) { - $profile = $this->model->findPaymentProfile($customer); + $profile = $this->getHostObject()->findPaymentProfile($customer); $profileData = $profile ? (array)$profile->profile_data : []; $response = $this->createOrFetchCustomer($profileData, $customer); @@ -452,7 +452,7 @@ protected function handleUpdatePaymentProfile($customer, $data) $cardId = $response->getCard()->getId(); if (!$profile) { - $profile = $this->model->initPaymentProfile($customer); + $profile = $this->getHostObject()->initPaymentProfile($customer); } $this->updatePaymentProfileData($profile, [ diff --git a/src/Payments/Stripe.php b/src/Payments/Stripe.php index 068f664..445f980 100644 --- a/src/Payments/Stripe.php +++ b/src/Payments/Stripe.php @@ -10,14 +10,15 @@ use Igniter\PayRegister\Concerns\WithPaymentProfile; use Igniter\PayRegister\Concerns\WithPaymentRefund; use Igniter\PayRegister\Models\PaymentProfile; +use Igniter\System\Traits\SessionMaker; use Igniter\User\Models\Customer; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; use Stripe\StripeClient; class Stripe extends BasePaymentGateway { + use SessionMaker; use WithAuthorizedPayment; use WithPaymentProfile; use WithPaymentRefund; @@ -119,7 +120,7 @@ public function createOrFetchIntent($order) return; } - $this->validateApplicableFee($order, $this->model); + $this->validateApplicableFee($order, $this->getHostObject()); $response = $this->updatePaymentIntentSession($order); @@ -127,10 +128,10 @@ public function createOrFetchIntent($order) $fields = $this->getPaymentFormFields($order); $stripeOptions = $this->getStripeOptions(); $response = $this->createGateway()->paymentIntents->create( - array_only($fields, ['amount', 'currency', 'capture_method', 'setup_future_usage', 'customer']), $stripeOptions + array_only($fields, ['amount', 'currency', 'capture_method', 'setup_future_usage', 'customer']), $stripeOptions, ); - Session::put($this->intentSessionKey, $response->id); + $this->putSession($this->intentSessionKey, $response->id); } return $response?->client_secret; @@ -156,7 +157,7 @@ public function processPaymentForm($data, $host, $order) $this->validateApplicableFee($order, $host); try { - if (!$intentId = Session::get($this->intentSessionKey)) { + if (!$intentId = $this->getSession($this->intentSessionKey)) { throw new Exception('Missing payment intent identifier in session.'); } @@ -203,7 +204,7 @@ public function processPaymentForm($data, $host, $order) $order->updateOrderStatus($host->order_status, ['notify' => false]); $order->markAsPaymentProcessed(); - Session::forget($this->intentSessionKey); + $this->forgetSession($this->intentSessionKey); } catch (Exception $ex) { logger()->error($ex); $order->logPaymentAttempt('Payment error: '.$ex->getMessage(), 0, $data, $paymentIntent ?? []); @@ -216,24 +217,24 @@ public function captureAuthorizedPayment(Order $order, $data = []) { throw_unless( $paymentLog = $order->payment_logs()->firstWhere('is_success', true), - new ApplicationException('No successful authorized payment to capture') + new ApplicationException('No successful authorized payment to capture'), ); throw_unless( $paymentIntentId = array_get($paymentLog->response, 'id'), - new ApplicationException('Missing payment intent ID in successful authorized payment response') + new ApplicationException('Missing payment intent ID in successful authorized payment response'), ); throw_if( - $order->payment !== $this->model->code, - new ApplicationException(sprintf('Invalid payment class for order: %s', $order->id)) + $order->payment !== $this->getHostObject()->code, + new ApplicationException(sprintf('Invalid payment class for order: %s', $order->id)), ); try { $response = $this->createGateway()->paymentIntents->capture( $paymentIntentId, $this->getPaymentCaptureFields($order, $data), - $this->getStripeOptions() + $this->getStripeOptions(), ); if ($response->status == 'succeeded') { @@ -253,17 +254,17 @@ public function cancelAuthorizedPayment(Order $order, $data = []) { throw_unless( $paymentLog = $order->payment_logs()->firstWhere('is_success', true), - new ApplicationException('No successful authorized payment to cancel') + new ApplicationException('No successful authorized payment to cancel'), ); throw_unless( $paymentIntentId = array_get($paymentLog->response, 'id'), - new ApplicationException('Missing payment intent ID in successful authorized payment response') + new ApplicationException('Missing payment intent ID in successful authorized payment response'), ); throw_if( - $order->payment !== $this->model->code, - new ApplicationException(sprintf('Invalid payment class for order: %s', $order->id)) + $order->payment !== $this->getHostObject()->code, + new ApplicationException(sprintf('Invalid payment class for order: %s', $order->id)), ); try { @@ -273,7 +274,7 @@ public function cancelAuthorizedPayment(Order $order, $data = []) } $response = $this->createGateway()->paymentIntents->cancel( - $paymentIntentId, $data, $this->getStripeOptions() + $paymentIntentId, $data, $this->getStripeOptions(), ); if ($response->status == 'canceled') { @@ -292,7 +293,7 @@ public function cancelAuthorizedPayment(Order $order, $data = []) public function updatePaymentIntentSession($order) { try { - if ($intentId = Session::get($this->intentSessionKey)) { + if ($intentId = $this->getSession($this->intentSessionKey)) { $gateway = $this->createGateway(); $stripeOptions = $this->getStripeOptions(); $paymentIntent = $gateway->paymentIntents->retrieve($intentId, [], $stripeOptions); @@ -371,7 +372,7 @@ public function payFromPaymentProfile(Order $order, array $data = []) $intent = $gateway->paymentIntents->create( array_except($fields, ['setup_future_usage']), - $stripeOptions + $stripeOptions, ); if ($intent->status !== 'succeeded') { @@ -467,7 +468,7 @@ public function processRefundForm($data, $order, $paymentLog) $message = sprintf('Payment intent %s refunded successfully -> (%s: %s)', $paymentChargeId, array_get($data, 'refund_type'), - array_get($response->toArray(), 'id') + array_get($response->toArray(), 'id'), ); $order->logPaymentAttempt($message, 1, $fields, $response->toArray()); @@ -521,7 +522,7 @@ protected function getPaymentRefundFields($order, $data) ? array_get($data, 'refund_amount') : $order->order_total; throw_if($refundAmount > $order->order_total, new ApplicationException( - 'Refund amount should be be less than or equal to the order total' + 'Refund amount should be be less than or equal to the order total', )); $fields = [ @@ -542,7 +543,7 @@ protected function createGateway() 'TastyIgniter Stripe', '1.0.0', 'https://tastyigniter.com/marketplace/item/igniter-payregister', - 'pp_partner_JZyCCGR3cOwj9S' // Used by Stripe to identify this integration + 'pp_partner_JZyCCGR3cOwj9S', // Used by Stripe to identify this integration ); $stripeClient = new StripeClient([ @@ -607,7 +608,7 @@ protected function getWebhookPayload(): array $event = \Stripe\Webhook::constructEvent( request()->getContent(), request()->header('stripe-signature'), - $webhookSecret + $webhookSecret, ); return $event->toArray(); diff --git a/tests/Classes/AuthorizeNetClientTest.php b/tests/Classes/AuthorizeNetClientTest.php new file mode 100644 index 0000000..dc346de --- /dev/null +++ b/tests/Classes/AuthorizeNetClientTest.php @@ -0,0 +1,87 @@ +authorizeNetClient = new AuthorizeNetClient; +}); + +it('creates authentication instance', function() { + $result = $this->authorizeNetClient->authentication(); + + expect($result)->toBeInstanceOf(MerchantAuthenticationType::class); +}); + +it('creates transaction request instance', function() { + $result = $this->authorizeNetClient->createTransactionRequest(); + + expect($result)->toBeInstanceOf(CreateTransactionRequest::class) + ->and($result->getMerchantAuthentication())->toBeInstanceOf(MerchantAuthenticationType::class); +}); + +it('returns existing transaction request instance if set', function() { + $result = $this->authorizeNetClient->createTransactionRequest(); + + expect($result)->toBeInstanceOf(CreateTransactionRequest::class); +}); + +it('throws exception if no response returned from create transaction', function() { + $request = Mockery::mock(CreateTransactionRequest::class); + $request->shouldReceive('getMerchantAuthentication')->andReturn(Mockery::mock(MerchantAuthenticationType::class)); + $request->shouldReceive('setClientId')->andReturnSelf(); + $request->shouldReceive('jsonSerialize')->andReturn([]); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No response returned'); + + $this->authorizeNetClient->createTransaction(new CreateTransactionController($request)); +}); + +it('throws exception if response result code is not Ok', function() { + $request = Mockery::mock(CreateTransactionRequest::class); + $request->shouldReceive('getMerchantAuthentication')->andReturn(Mockery::mock(MerchantAuthenticationType::class)); + $request->shouldReceive('setClientId')->andReturnSelf(); + $request->shouldReceive('jsonSerialize')->andReturn([]); + + $response = Mockery::mock(ANetApiResponseType::class); + $response->shouldReceive('getMessages->getResultCode')->andReturn('Error'); + + $transactionResponse = Mockery::mock(TransactionResponseType::class); + $response->shouldReceive('getTransactionResponse')->andReturn($transactionResponse); + + $this->expectException(ApplicationException::class); + + $this->authorizeNetClient->createTransaction(new CreateTransactionController($request)); +}); + +it('returns transaction response if successful', function() { + $response = Mockery::mock(ANetApiResponseType::class); + $response->shouldReceive('getMessages->getResultCode')->andReturn('Ok'); + + $request = Mockery::mock(CreateTransactionRequest::class); + $request->shouldReceive('getMerchantAuthentication')->andReturn(Mockery::mock(MerchantAuthenticationType::class)); + $request->shouldReceive('setClientId')->andReturnSelf(); + $request->shouldReceive('jsonSerialize')->andReturn([$response]); + + $transactionResponse = Mockery::mock(TransactionResponseType::class); + $transactionResponse->shouldReceive('getMessages')->andReturn(Mockery::mock(MessagesType::class)); + $response->shouldReceive('getTransactionResponse')->andReturn($transactionResponse); + + $controller = Mockery::mock(CreateTransactionController::class); + $controller->shouldReceive('executeWithApiResponse')->andReturn($response); + app()->instance(CreateTransactionController::class, $controller); + + $result = $this->authorizeNetClient->createTransaction($controller); + + expect($result)->toBe($transactionResponse); +}); diff --git a/tests/Classes/BasePaymentGatewayTest.php b/tests/Classes/BasePaymentGatewayTest.php new file mode 100644 index 0000000..dea6125 --- /dev/null +++ b/tests/Classes/BasePaymentGatewayTest.php @@ -0,0 +1,110 @@ +model = Mockery::mock(Model::class); + $this->gateway = new class($this->model) extends BasePaymentGateway + { + public function defineFieldsConfig() + { + return __DIR__.'/../_fixtures/fields'; + } + + public function getModel() + { + return $this->createOrderModel(); + } + + public function getStatusModel() + { + return $this->createOrderStatusModel(); + } + }; +}); + +it('initializes with default config data if model does not exist', function() { + $host = Mockery::mock(Model::class); + $host->exists = false; + $gateway = Mockery::mock(BasePaymentGateway::class)->makePartial(); + $gateway->shouldReceive('initConfigData')->with($host)->once(); + + $gateway->initialize($host); +}); + +it('does not initialize config data if model exists', function() { + $host = Mockery::mock(Model::class); + $host->exists = true; + + $gateway = Mockery::mock(BasePaymentGateway::class)->makePartial(); + $gateway->shouldNotReceive('initConfigData'); + + $gateway->initialize($host); +}); + +it('returns correct config fields', function() { + $result = $this->gateway->getConfigFields(); + expect($result)->toBe([ + 'test_field' => [ + 'label' => 'Test Field', + 'type' => 'text', + ], + ]); +}); + +it('returns correct config rules', function() { + $result = $this->gateway->getConfigRules(); + + expect($result)->toBe([ + ['test_field', 'lang:igniter.payregister::default.stripe.label_test_field', 'required|string'], + ]); +}); + +it('returns correct config validation attributes', function() { + $result = $this->gateway->getConfigValidationAttributes(); + + expect($result)->toBe([ + 'test_field' => 'lang:igniter.payregister::default.stripe.label_test_field', + ]); +}); + +it('returns correct config validation messages', function() { + $result = $this->gateway->getConfigValidationMessages(); + + expect($result)->toBe([ + 'test_field.required' => 'lang:igniter.payregister::default.stripe.label_test_field', + 'test_field.string' => 'lang:igniter.payregister::default.stripe.label_test_field', + ]); +}); + +it('creates correct entry point URL', function() { + $code = 'test_code'; + $result = $this->gateway->makeEntryPointUrl($code); + + expect($result)->toBe(URL::to('ti_payregister/'.$code)); +}); + +it('returns false for completes payment on client', function() { + $result = $this->gateway->completesPaymentOnClient(); + + expect($result)->toBeFalse(); +}); + +it('creates an instance of the order model', function() { + $result = $this->gateway->getModel(); + + expect($result)->toBeInstanceOf(Order::class); +}); + +it('creates an instance of the order status model', function() { + $result = $this->gateway->getStatusModel(); + + expect($result)->toBeInstanceOf(Status::class); +}); diff --git a/tests/Classes/PayPalClientTest.php b/tests/Classes/PayPalClientTest.php new file mode 100644 index 0000000..c5d1705 --- /dev/null +++ b/tests/Classes/PayPalClientTest.php @@ -0,0 +1,128 @@ +clientId = 'testClientId'; + $this->clientSecret = 'testClientSecret'; + $this->sandbox = true; + $this->payPalClient = new PayPalClient($this->clientId, $this->clientSecret, $this->sandbox); +}); + +function mockGenerateAccessToken() +{ + Cache::shouldReceive('has')->with('payregister_paypal_access_token')->andReturn(false); + Cache::shouldReceive('put')->once(); + Cache::shouldReceive('get')->with('payregister_paypal_access_token')->andReturn('testAccessToken'); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ + 'access_token' => 'testAccessToken', + 'expires_in' => 3600, + ], 200), + ]); +} + +it('throws exception if client ID is not configured', function() { + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('PayPal client ID is not configured'); + + new PayPalClient(null, $this->clientSecret, $this->sandbox); +}); + +it('throws exception if client secret is not configured', function() { + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('PayPal client secret is not configured'); + + new PayPalClient($this->clientId, null, $this->sandbox); +}); + +it('gets order details successfully', function() { + mockGenerateAccessToken(); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v2/checkout/orders/*' => Http::response(['id' => 'testOrderId'], 200), + ]); + + $response = $this->payPalClient->getOrder('testOrderId'); + + expect($response->json('id'))->toBe('testOrderId'); +}); + +it('creates order successfully', function() { + mockGenerateAccessToken(); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v2/checkout/orders' => Http::response(['id' => 'testOrderId'], 201), + ]); + + $response = $this->payPalClient->createOrder(['intent' => 'CAPTURE']); + + expect($response->json('id'))->toBe('testOrderId'); +}); + +it('captures order successfully', function() { + mockGenerateAccessToken(); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v2/checkout/orders/testOrderId/capture' => Http::response(['status' => 'COMPLETED'], 200), + ]); + + $response = $this->payPalClient->captureOrder('testOrderId'); + + expect($response->json('status'))->toBe('COMPLETED'); +}); + +it('authorizes order successfully', function() { + mockGenerateAccessToken(); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v2/checkout/orders/testOrderId/authorize' => Http::response(['status' => 'AUTHORIZED'], 200), + ]); + + $response = $this->payPalClient->authorizeOrder('testOrderId'); + + expect($response->json('status'))->toBe('AUTHORIZED'); +}); + +it('gets payment details successfully', function() { + mockGenerateAccessToken(); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/payments/capture/*' => Http::response(['id' => 'testPaymentId'], 200), + ]); + + $response = $this->payPalClient->getPayment('testPaymentId'); + + expect($response->json('id'))->toBe('testPaymentId'); +}); + +it('refunds payment successfully', function() { + mockGenerateAccessToken(); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v2/payments/captures/testPaymentId/refund' => Http::response(['status' => 'COMPLETED'], 201), + ]); + + $response = $this->payPalClient->refundPayment('testPaymentId', ['amount' => ['value' => '10.00', 'currency_code' => 'USD']]); + + expect($response->json('status'))->toBe('COMPLETED'); +}); + +it('throws exception if access token generation fails', function() { + Cache::shouldReceive('has')->with('payregister_paypal_access_token')->andReturn(false); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([], 400), + ]); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Failed to generate access token'); + + $this->payPalClient->getOrder(123); +}); diff --git a/tests/Classes/PaymentGatewaysTest.php b/tests/Classes/PaymentGatewaysTest.php new file mode 100644 index 0000000..1a1b5dc --- /dev/null +++ b/tests/Classes/PaymentGatewaysTest.php @@ -0,0 +1,83 @@ +paymentGateways = new PaymentGateways(); +}); + +it('returns null if gateway not found', function() { + $result = $this->paymentGateways->findGateway('nonexistent'); + + expect($result)->toBeNull(); +}); + +it('returns gateway details if gateway found', function() { + $gateway = ['code' => 'test_gateway', 'class' => TestPayment::class]; + $this->paymentGateways->registerGateways('test_owner', [$gateway['class'] => $gateway]); + + $result = $this->paymentGateways->findGateway('test_gateway'); + + expect($result['code'])->toBe($gateway['code']) + ->and($result['class'])->toBe($gateway['class']) + ->and($result['owner'])->toBe('test_owner') + ->and($result['object'])->toBeInstanceOf(TestPayment::class); +}); + +it('returns list of gateway objects', function() { + $gateway = ['code' => 'test_gateway', 'class' => TestPayment::class]; + $this->paymentGateways->registerGateways('test_owner', [$gateway['class'] => $gateway]); + + $result = $this->paymentGateways->listGatewayObjects(); + + expect($result)->toHaveKey('test_gateway') + ->and($result['test_gateway'])->toBeInstanceOf(TestPayment::class); +}); + +it('loads gateways from extensions', function() { + $extensionManager = Mockery::mock(ExtensionManager::class); + $extensionManager->shouldReceive('getExtensions')->andReturn([ + 'test_extension' => new class + { + public function registerPaymentGateways() + { + return [ + TestPayment::class => [ + 'code' => 'test_gateway', + ], + ]; + } + }, + ]); + app()->instance(ExtensionManager::class, $extensionManager); + + $this->paymentGateways->listGateways(); + + $result = $this->paymentGateways->findGateway('test_gateway'); + + expect($result)->toBeArray() + ->and($result['code'])->toBe('test_gateway'); +}); + +it('executes entry point and returns response', function() { + Payment::factory()->create([ + 'code' => 'test_code', + 'class_name' => TestPayment::class, + ]); + + $result = PaymentGateways::runEntryPoint('test_endpoint', 'test/uri'); + + expect($result)->toBe('test_endpoint'); +}); + +it('returns 403 response if entry point not found', function() { + $result = PaymentGateways::runEntryPoint('invalid_code', 'test/uri'); + + expect($result->getStatusCode())->toBe(403); +}); diff --git a/tests/Concerns/WithApplicableFeeTest.php b/tests/Concerns/WithApplicableFeeTest.php new file mode 100644 index 0000000..3618815 --- /dev/null +++ b/tests/Concerns/WithApplicableFeeTest.php @@ -0,0 +1,118 @@ +order = Order::factory()->create([ + 'payment' => 'test_code', + 'order_total' => 110.00, + ]); + $this->payment = Payment::factory()->create([ + 'code' => 'test_code', + ]); + $this->payment->order_total = 100.00; + $this->order->payment_method = $this->payment; + $this->trait = new class($this->payment) extends BasePaymentGateway + { + use WithApplicableFee; + + public function defineFieldsConfig() + { + return __DIR__.'/../_fixtures/fields'; + } + + public function validatesApplicable($order): void + { + $this->validateApplicableFee($order, $this->model); + } + }; +}); + +it('validates applicable fee successfully', function() { + $this->trait->validatesApplicable($this->order); + + expect(true)->toBeTrue(); +}); + +it('throws exception if payment method not found', function() { + $this->order->payment_method = null; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Payment method not found'); + + $this->trait->validatesApplicable($this->order); +}); + +it('throws exception if payment method code does not match', function() { + $clonedPayment = clone $this->payment; + $clonedPayment->code = 'another_code'; + $this->order->payment_method = $clonedPayment; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Payment method not found'); + + $this->trait->validatesApplicable($this->order); +}); + +it('throws exception if order total is not applicable', function() { + $this->order->order_total = 90.00; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('You need to spend £100.00 or more to pay with '.$this->payment->name); + + $this->trait->validatesApplicable($this->order); +}); + +it('returns true if payment type is applicable for specified order amount', function() { + $result = $this->trait->isApplicable(150.00, $this->payment); + + expect($result)->toBeTrue(); +}); + +it('returns false if payment type is not applicable for specified order amount', function() { + $this->payment->order_total = 150.00; + + $result = $this->trait->isApplicable(100.00, $this->payment); + + expect($result)->toBeFalse(); +}); + +it('returns true if payment type has additional fee', function() { + $this->payment->order_fee = 10.00; + + $result = $this->trait->hasApplicableFee($this->payment); + + expect($result)->toBeTrue(); +}); + +it('returns false if payment type does not have additional fee', function() { + $this->payment->order_fee = 0.00; + + $result = $this->trait->hasApplicableFee($this->payment); + + expect($result)->toBeFalse(); +}); + +it('returns formatted applicable fee as percentage', function() { + $this->payment->order_fee_type = 2; + $this->payment->order_fee = 10.00; + + $result = $this->trait->getFormattedApplicableFee($this->payment); + + expect($result)->toBe('10%'); +}); + +it('returns formatted applicable fee as currency', function() { + $this->payment->order_fee_type = 1; + $this->payment->order_fee = 10.00; + + $result = $this->trait->getFormattedApplicableFee($this->payment); + + expect($result)->toBe('£10.00'); +}); diff --git a/tests/Concerns/WithAuthorizedPaymentTest.php b/tests/Concerns/WithAuthorizedPaymentTest.php new file mode 100644 index 0000000..f97284d --- /dev/null +++ b/tests/Concerns/WithAuthorizedPaymentTest.php @@ -0,0 +1,71 @@ +order = Order::factory()->create([ + 'payment' => 'test_code', + 'status_id' => 1, + ]); + $this->payment = Payment::factory()->create([ + 'code' => 'test_code', + ]); + $this->order->payment_method = $this->payment; + $this->trait = new class($this->payment) extends BasePaymentGateway + { + use WithAuthorizedPayment; + + public function defineFieldsConfig() + { + return __DIR__.'/../_fixtures/fields'; + } + + public function validatesApplicable($order): void + { + $this->validateApplicableFee($order, $this->model); + } + }; +}); + +it('throws exception if shouldAuthorizePayment is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Method shouldAuthorizePayment must be implemented on your custom payment class.'); + + $this->trait->shouldAuthorizePayment(); +}); + +it('returns true if order status matches capture status', function() { + $this->payment->capture_status = 1; + + $result = $this->trait->shouldCapturePayment($this->order); + + expect($result)->toBeTrue(); +}); + +it('returns false if order status does not match capture status', function() { + $this->payment->capture_status = 1; + $this->order->status_id = 2; + + $result = $this->trait->shouldCapturePayment($this->order); + + expect($result)->toBeFalse(); +}); + +it('throws exception if captureAuthorizedPayment is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Method captureAuthorizedPayment must be implemented on your custom payment class.'); + + $this->trait->captureAuthorizedPayment($this->order); +}); + +it('throws exception if cancelAuthorizedPayment is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Method cancelAuthorizedPayment must be implemented on your custom payment class.'); + + $this->trait->cancelAuthorizedPayment($this->order); +}); diff --git a/tests/Concerns/WithPaymentProfileTest.php b/tests/Concerns/WithPaymentProfileTest.php new file mode 100644 index 0000000..0424f71 --- /dev/null +++ b/tests/Concerns/WithPaymentProfileTest.php @@ -0,0 +1,48 @@ +customer = Customer::factory()->create(); + $this->order = Mockery::mock(Order::class); + $this->profile = Mockery::mock(PaymentProfile::class); + $payment = Mockery::mock(Payment::class); + $this->trait = new class($payment) extends BasePaymentGateway + { + use WithPaymentProfile; + + public function defineFieldsConfig() + { + return __DIR__.'/../_fixtures/fields'; + } + }; +}); + +it('throws exception if updatePaymentProfile is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Method updatePaymentProfile must be implemented on your custom payment class.'); + + $this->trait->updatePaymentProfile($this->customer, []); +}); + +it('throws exception if deletePaymentProfile is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Method deletePaymentProfile must be implemented on your custom payment class.'); + + $this->trait->deletePaymentProfile($this->customer, $this->profile); +}); + +it('throws exception if payFromPaymentProfile is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Method payFromPaymentProfile must be implemented on your custom payment class.'); + + $this->trait->payFromPaymentProfile($this->order, []); +}); diff --git a/tests/Concerns/WithPaymentRefundTest.php b/tests/Concerns/WithPaymentRefundTest.php new file mode 100644 index 0000000..598bed2 --- /dev/null +++ b/tests/Concerns/WithPaymentRefundTest.php @@ -0,0 +1,37 @@ +order = Mockery::mock(Order::class); + $this->paymentLog = Mockery::mock(PaymentLog::class); + $this->trait = Mockery::mock(WithPaymentRefund::class)->makePartial(); +}); + +it('returns true if payment is refundable', function() { + $this->paymentLog->shouldReceive('extendableGet')->with('is_refundable')->andReturn(true); + + $result = $this->trait->canRefundPayment($this->paymentLog); + + expect($result)->toBeTrue(); +}); + +it('returns false if payment is not refundable', function() { + $this->paymentLog->shouldReceive('extendableGet')->with('is_refundable')->andReturn(false); + + $result = $this->trait->canRefundPayment($this->paymentLog); + + expect($result)->toBeFalse(); +}); + +it('throws exception if processRefundForm is not implemented', function() { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Please implement the processRefundForm method on your custom payment class.'); + + $this->trait->processRefundForm([], $this->order, $this->paymentLog); +}); diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 6503bec..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/ExtensionTest.php b/tests/ExtensionTest.php new file mode 100644 index 0000000..51fc2f7 --- /dev/null +++ b/tests/ExtensionTest.php @@ -0,0 +1,131 @@ +extension = new Extension(app()); +}); + +it('registers payment gateways', function() { + $gateways = $this->extension->registerPaymentGateways(); + expect($gateways)->toHaveKey(Cod::class) + ->and($gateways[Cod::class]['code'])->toBe('cod') + ->and($gateways[Cod::class]['name'])->toBe('lang:igniter.payregister::default.cod.text_payment_title') + ->and($gateways[Cod::class]['description'])->toBe('lang:igniter.payregister::default.cod.text_payment_desc') + ->and($gateways)->toHaveKey(PaypalExpress::class) + ->and($gateways[PaypalExpress::class]['code'])->toBe('paypalexpress') + ->and($gateways[PaypalExpress::class]['name'])->toBe('lang:igniter.payregister::default.paypal.text_payment_title') + ->and($gateways[PaypalExpress::class]['description'])->toBe('lang:igniter.payregister::default.paypal.text_payment_desc') + ->and($gateways)->toHaveKey(AuthorizeNetAim::class) + ->and($gateways[AuthorizeNetAim::class]['code'])->toBe('authorizenetaim') + ->and($gateways[AuthorizeNetAim::class]['name'])->toBe('lang:igniter.payregister::default.authorize_net_aim.text_payment_title') + ->and($gateways[AuthorizeNetAim::class]['description'])->toBe('lang:igniter.payregister::default.authorize_net_aim.text_payment_desc') + ->and($gateways)->toHaveKey(Stripe::class) + ->and($gateways[Stripe::class]['code'])->toBe('stripe') + ->and($gateways[Stripe::class]['name'])->toBe('lang:igniter.payregister::default.stripe.text_payment_title') + ->and($gateways[Stripe::class]['description'])->toBe('lang:igniter.payregister::default.stripe.text_payment_desc') + ->and($gateways)->toHaveKey(Mollie::class) + ->and($gateways[Mollie::class]['code'])->toBe('mollie') + ->and($gateways[Mollie::class]['name'])->toBe('lang:igniter.payregister::default.mollie.text_payment_title') + ->and($gateways[Mollie::class]['description'])->toBe('lang:igniter.payregister::default.mollie.text_payment_desc') + ->and($gateways)->toHaveKey(Square::class) + ->and($gateways[Square::class]['code'])->toBe('square') + ->and($gateways[Square::class]['name'])->toBe('lang:igniter.payregister::default.square.text_payment_title') + ->and($gateways[Square::class]['description'])->toBe('lang:igniter.payregister::default.square.text_payment_desc'); +}); + +it('registers form widgets', function() { + $widgets = $this->extension->registerFormWidgets(); + + expect($widgets)->toHaveKey(PaymentAttempts::class) + ->and($widgets[PaymentAttempts::class]['code'])->toBe('paymentattempts'); +}); + +it('registers settings', function() { + $settings = $this->extension->registerSettings(); + + expect($settings)->toHaveKey('settings') + ->and($settings['settings']['label'])->toBe(lang('igniter.payregister::default.text_side_menu')); +}); + +it('registers permissions', function() { + $permissions = $this->extension->registerPermissions(); + + expect($permissions)->toHaveKey('Admin.Payments') + ->and($permissions['Admin.Payments']['label'])->toBe('igniter.payregister::default.help_permission'); +}); + +it('registers onboarding steps', function() { + $steps = $this->extension->registerOnboardingSteps(); + + expect($steps)->toHaveKey('igniter.payregister::payments') + ->and($steps['igniter.payregister::payments']['label'])->toBe('igniter.payregister::default.onboarding_payments'); +}); + +it('subscribes to events', function() { + $extension = new class(app()) extends Extension + { + public function subscribers(): array + { + return $this->subscribe; + } + }; + + expect($extension->subscribers())->toContain(FormFieldsSubscriber::class); +}); + +it('listens to events', function() { + $extension = new class(app()) extends Extension + { + public function listeners(): array + { + return $this->listen; + } + }; + + $listeners = $extension->listeners(); + + expect($listeners)->toHaveKey('igniter.checkout.afterSaveOrder') + ->and($listeners['igniter.checkout.afterSaveOrder'])->toContain(UpdatePaymentIntentSessionOnCheckout::class); +}); + +it('registers observers', function() { + $extension = new class(app()) extends Extension + { + public function observers(): array + { + return $this->observers; + } + }; + + $observers = $extension->observers(); + + expect($observers)->toHaveKey(Payment::class) + ->and($observers[Payment::class])->toBe(PaymentObserver::class); +}); + +it('registers singletons', function() { + expect($this->extension->singletons)->toContain(PaymentGateways::class); +}); + +it('syncs payments on theme activation', function() { + Event::shouldReceive('listen')->with('main.theme.activated', Mockery::any())->once(); + + $this->extension->boot(); +}); diff --git a/tests/FormWidgets/PaymentAttemptsTest.php b/tests/FormWidgets/PaymentAttemptsTest.php new file mode 100644 index 0000000..4846e03 --- /dev/null +++ b/tests/FormWidgets/PaymentAttemptsTest.php @@ -0,0 +1,102 @@ +order = Order::factory()->state([ + 'order_total' => 100.00, + ])->create(); + $this->paymentLog = PaymentLog::factory()->for($this->order)->create(); + $this->widget = new PaymentAttempts( + resolve(Orders::class), + new FormField('test_field', 'Payment attempts'), + [ + 'model' => $this->order, + 'form' => [ + 'fields' => [ + 'payment_log_id' => [ + 'type' => 'hidden', + ], + ], + ], + 'columns' => ['order_id', 'order_total'], + ], + ); +}); + +it('initializes with config', function() { + expect($this->widget->form)->toBe(['fields' => [ + 'payment_log_id' => [ + 'type' => 'hidden', + ], + ]])->and($this->widget->formTitle)->toBe('igniter.payregister::default.text_refund_title'); +}); + +it('prepares vars', function() { + $this->widget->prepareVars(); + + expect($this->widget->vars['field'])->toBeInstanceOf(FormField::class) + ->and($this->widget->vars['dataTableWidget'])->toBeInstanceOf(DataTable::class); +}); + +it('returns no save data for getSaveValue', function() { + $result = $this->widget->getSaveValue('some_value'); + + expect($result)->toBe(FormField::NO_SAVE_DATA); +}); + +it('throws exception if record not found on load record', function() { + $this->expectException(FlashException::class); + $this->expectExceptionMessage('Record not found'); + + request()->merge(['recordId' => '1']); + + $this->widget->onLoadRecord(); +}); + +it('loads record successfully', function() { + request()->merge(['recordId' => $this->paymentLog->getKey()]); + $result = $this->widget->onLoadRecord(); + + expect($result)->toContain('Refund: £100.00'); +}); + +it('throws exception if no successful payment to refund', function() { + $payment = Payment::factory()->create([ + 'class_name' => TestPaymentWithNoRefund::class, + ]); + $payment->applyGatewayClass(); + $this->order->payment_method = $payment; + + $this->expectException(FlashException::class); + $this->expectExceptionMessage('No successful payment to refund'); + + request()->merge(['recordId' => $this->paymentLog->getKey()]); + $this->widget->onSaveRecord(); +}); + +it('saves record successfully', function() { + $payment = Payment::factory()->create([ + 'class_name' => TestPaymentWithRefund::class, + ]); + $payment->applyGatewayClass(); + $this->order->payment_method = $payment; + + request()->merge(['recordId' => $this->paymentLog->getKey()]); + $result = $this->widget->onSaveRecord(); + + expect($result)->toBeArray() + ->toHaveKey('#notification') + ->toHaveKey('~#paymentattempts-test-field'); +}); diff --git a/tests/Http/Controllers/PaymentsTest.php b/tests/Http/Controllers/PaymentsTest.php new file mode 100644 index 0000000..0d754f7 --- /dev/null +++ b/tests/Http/Controllers/PaymentsTest.php @@ -0,0 +1,183 @@ +get(route('igniter.payregister.payments')) + ->assertOk(); +}); + +it('loads create payment page', function() { + actingAsSuperUser() + ->get(route('igniter.payregister.payments', ['slug' => 'create'])) + ->assertOk(); +}); + +it('loads edit payment page', function() { + $payment = Payment::factory()->create(); + + actingAsSuperUser() + ->get(route('igniter.payregister.payments', ['slug' => 'edit/'.$payment->code])) + ->assertOk(); +}); + +it('sets icon class to fa-star if record is default', function() { + $payment = Payment::factory()->create([ + 'is_default' => 1, + ]); + $column = new ListColumn('default', 'Default'); + $column->type = 'button'; + $column->columnName = 'default'; + + (new Payments)->listOverrideColumnValue($payment, $column); + + expect($column->iconCssClass)->toBe('fa fa-star'); +}); + +it('sets icon class to fa-star-o if record is not default', function() { + $payment = Payment::factory()->create([ + 'is_default' => 0, + ]); + $column = new ListColumn('default', 'Default'); + $column->type = 'button'; + $column->columnName = 'default'; + + (new Payments)->listOverrideColumnValue($payment, $column); + + expect($column->iconCssClass)->toBe('fa fa-star-o'); +}); + +it('applies gateway class if model does not exist', function() { + $model = Mockery::mock(); + $model->exists = false; + $model->shouldReceive('applyGatewayClass'); + + $extendedModel = (new Payments)->formExtendModel($model); + + expect($extendedModel)->toBe($model); +}); + +it('does not apply gateway class if model exists', function() { + $model = Mockery::mock(Payment::class); + $model->exists = true; + $model->shouldNotReceive('applyGatewayClass'); + + $extendedModel = (new Payments)->formExtendModel($model); + + expect($extendedModel)->toBe($model); +}); + +it('extends form fields before create context', function() { + $model = Mockery::mock(Payment::class); + $model->shouldReceive('getConfigFields')->andReturn(['field1' => ['label' => 'Field1']]); + $model->exists = true; + + $form = new \stdClass; + $form->model = $model; + $form->context = 'create'; + $form->tabs = ['fields' => ['field2' => ['label' => 'Field2']]]; + + (new Payments)->formExtendFieldsBefore($form); + + expect($form->tabs['fields'])->toBe(['field2' => ['label' => 'Field2'], 'field1' => ['label' => 'Field1']]); +}); + +it('extends form fields before non-create context', function() { + $model = Mockery::mock(Payment::class); + $model->shouldReceive('getConfigFields')->andReturn(['field1' => ['label' => 'Field1']]); + $model->exists = true; + + $form = new \stdClass; + $form->model = $model; + $form->context = 'edit'; + $form->tabs = ['fields' => ['field2' => ['label' => 'Field2']]]; + + (new Payments)->formExtendFieldsBefore($form); + + expect($form->tabs['fields'])->toBe(['field2' => ['label' => 'Field2'], 'field1' => ['label' => 'Field1']]) + ->and($form->fields['code']['disabled'])->toBeTrue(); +}); + +it('sets a default payment', function() { + $payment = Payment::factory()->create(); + + actingAsSuperUser() + ->post(route('igniter.payregister.payments'), [ + 'default' => $payment->code, + ], [ + 'X-Requested-With' => 'XMLHttpRequest', + 'X-IGNITER-REQUEST-HANDLER' => 'onSetDefault', + ]); + + expect(Payment::getDefault()->code)->toBe($payment->code); +}); + +it('creates payment', function() { + $paymentGateways = Mockery::mock(PaymentGateways::class); + $paymentGateways->shouldReceive('findGateway')->andReturn([ + 'class' => TestPayment::class, + ]); + app()->instance(PaymentGateways::class, $paymentGateways); + + actingAsSuperUser() + ->post(route('igniter.payregister.payments', ['slug' => 'create']), [ + 'Payment' => [ + 'name' => 'Created Payment', + 'code' => 'test_code', + 'payment' => 'created_payment', + 'description' => 'Created Payment Description', + 'class_name' => TestPayment::class, + 'is_default' => 1, + 'priority' => 1, + 'status' => 1, + ], + ], [ + 'X-Requested-With' => 'XMLHttpRequest', + 'X-IGNITER-REQUEST-HANDLER' => 'onSave', + ]); + + expect(Payment::where('name', 'Created Payment')->exists())->toBeTrue(); +}); + +it('updates payment', function() { + $payment = Payment::factory()->create(); + + actingAsSuperUser() + ->post(route('igniter.payregister.payments', ['slug' => 'edit/'.$payment->code]), [ + 'Payment' => [ + 'name' => 'Updated Payment', + 'code' => 'updated_payment', + 'description' => 'Updated Payment Description', + 'class_name' => TestPayment::class, + 'priority' => 1, + 'is_default' => 1, + 'status' => 1, + 'test_field' => 'test_value', + ], + ], [ + 'X-Requested-With' => 'XMLHttpRequest', + 'X-IGNITER-REQUEST-HANDLER' => 'onSave', + ]); + + expect(Payment::where('name', 'Updated Payment')->exists())->toBeTrue(); +}); + +it('deletes payment', function() { + $payment = Payment::factory()->create(); + + actingAsSuperUser() + ->post(route('igniter.payregister.payments', ['slug' => 'edit/'.$payment->code]), [], [ + 'X-Requested-With' => 'XMLHttpRequest', + 'X-IGNITER-REQUEST-HANDLER' => 'onDelete', + ]); + + expect(Payment::where('code', $payment->code)->exists())->toBeFalse(); +}); diff --git a/tests/Listeners/CaptureAuthorizedPaymentTest.php b/tests/Listeners/CaptureAuthorizedPaymentTest.php new file mode 100644 index 0000000..b1de37e --- /dev/null +++ b/tests/Listeners/CaptureAuthorizedPaymentTest.php @@ -0,0 +1,65 @@ +listener = new CaptureAuthorizedPayment(); + $this->order = Order::factory()->create(); + $this->paymentMethod = Payment::factory()->create(); + $this->statusHistory = StatusHistory::factory()->create(); + $this->order->payment_method = $this->paymentMethod; +}); + +it('does nothing if order is not an instance of Order', function() { + $model = Mockery::mock(Model::class); + + $result = $this->listener->handle($model, $this->statusHistory); + + expect($result)->toBeNull(); +}); + +it('does nothing if payment method is not set', function() { + $this->order->payment_method = null; + + $result = $this->listener->handle($this->order, $this->statusHistory); + + expect($result)->toBeNull(); +}); + +it('does nothing if payment method does not use WithAuthorizedPayment', function() { + $this->paymentMethod->class_name = TestPayment::class; + + $result = $this->listener->handle($this->order, $this->statusHistory); + + expect($result)->toBeNull(); +}); + +it('does nothing if shouldCapturePayment returns false', function() { + $paymentMethod = Mockery::mock(Payment::class)->makePartial(); + $paymentMethod->shouldReceive('getGatewayClass')->andReturn(TestPaymentWithAuthorized::class); + $paymentMethod->shouldReceive('shouldCapturePayment')->with($this->order)->andReturn(false); + $this->order->payment_method = $paymentMethod; + + $result = $this->listener->handle($this->order, $this->statusHistory); + + expect($result)->toBeNull(); +}); + +it('calls captureAuthorizedPayment if shouldCapturePayment returns true', function() { + $paymentMethod = Mockery::mock(Payment::class)->makePartial(); + $paymentMethod->shouldReceive('getGatewayClass')->andReturn(TestPaymentWithAuthorized::class); + $paymentMethod->shouldReceive('shouldCapturePayment')->with($this->order)->andReturn(true); + $paymentMethod->shouldReceive('captureAuthorizedPayment')->with($this->order)->once(); + $this->order->payment_method = $paymentMethod; + + $result = $this->listener->handle($this->order, $this->statusHistory); +}); diff --git a/tests/Listeners/UpdatePaymentIntentSessionOnCheckoutTest.php b/tests/Listeners/UpdatePaymentIntentSessionOnCheckoutTest.php new file mode 100644 index 0000000..b34037a --- /dev/null +++ b/tests/Listeners/UpdatePaymentIntentSessionOnCheckoutTest.php @@ -0,0 +1,44 @@ +listener = new UpdatePaymentIntentSessionOnCheckout(); + $this->order = Order::factory()->create(); + $this->paymentMethod = Mockery::mock(Payment::class)->makePartial(); + $this->order->payment_method = $this->paymentMethod; +}); + +it('does nothing if payment method is not set', function() { + $this->order->payment_method = null; + + $result = $this->listener->handle($this->order); + + expect($result)->toBeNull(); +}); + +it('does nothing if payment method is not an instance of Payment', function() { + $result = $this->listener->handle($this->order); + + expect($result)->toBeNull(); +}); + +it('does nothing if updatePaymentIntentSession method does not exist', function() { + $this->paymentMethod->shouldReceive('methodExists')->with('updatePaymentIntentSession')->andReturn(false); + + $result = $this->listener->handle($this->order); + + expect($result)->toBeNull(); +}); + +it('calls updatePaymentIntentSession if method exists', function() { + $this->paymentMethod->shouldReceive('methodExists')->with('updatePaymentIntentSession')->andReturn(true); + $this->paymentMethod->shouldReceive('updatePaymentIntentSession')->with($this->order)->once(); + + $this->listener->handle($this->order); +}); diff --git a/tests/Models/PaymentLogTest.php b/tests/Models/PaymentLogTest.php new file mode 100644 index 0000000..251573e --- /dev/null +++ b/tests/Models/PaymentLogTest.php @@ -0,0 +1,89 @@ +paymentLog = new PaymentLog(); + $this->order = Order::factory()->create(); + $this->paymentMethod = Mockery::mock(Payment::class)->makePartial(); + $this->order->payment_method = $this->paymentMethod; +}); + +it('logs a successful payment attempt', function() { + $this->paymentMethod->code = 'test_code'; + $this->paymentMethod->name = 'Test Payment'; + + PaymentLog::logAttempt($this->order, 'Payment successful', true, ['request' => 'data'], ['response' => 'data'], true); + + $log = PaymentLog::where('order_id', $this->order->getKey())->first(); + expect($log->message)->toBe('Payment successful') + ->and($log->is_success)->toBeTrue() + ->and($log->is_refundable)->toBeTrue(); +}); + +it('logs a failed payment attempt', function() { + $this->paymentMethod->code = 'test_code'; + $this->paymentMethod->name = 'Test Payment'; + + PaymentLog::logAttempt($this->order, 'Payment failed', false, ['request' => 'data'], ['response' => 'data'], false); + + $log = PaymentLog::where('order_id', $this->order->getKey())->first(); + expect($log->message)->toBe('Payment failed') + ->and($log->is_success)->toBeFalse() + ->and($log->is_refundable)->toBeFalse(); +}); + +it('returns date added since attribute', function() { + $this->paymentLog->created_at = Carbon::now()->subMinutes(5); + + expect($this->paymentLog->date_added_since)->toBe('5 minutes ago'); +}); + +it('marks payment log as refund processed', function() { + $this->paymentLog->order = $this->order; + $this->paymentLog->refunded_at = null; + + $result = $this->paymentLog->markAsRefundProcessed(); + + expect($result)->toBeTrue() + ->and($this->paymentLog->refunded_at)->not->toBeNull(); +}); + +it('does not mark payment log as refund processed if already refunded', function() { + $this->paymentLog->refunded_at = Carbon::now(); + + $result = $this->paymentLog->markAsRefundProcessed(); + + expect($result)->toBeTrue() + ->and($this->paymentLog->refunded_at)->not->toBeNull(); +}); + +it('configures payment log model correctly', function() { + $payment = new PaymentLog(); + + expect(class_uses_recursive($payment)) + ->toContain(Validation::class) + ->and($payment->getTable())->toBe('payment_logs') + ->and($payment->getKeyName())->toBe('payment_log_id') + ->and($payment->timestamps)->toBeTrue() + ->and($payment->getAppends())->toContain('date_added_since') + ->and($payment->getCasts())->toHaveKeys(['order_id', 'request', 'response', 'is_success', 'is_refundable', 'refunded_at']) + ->and($payment->getMorphClass())->toBe('payment_logs') + ->and($payment->rules)->toBe([ + 'message' => 'string', + 'order_id' => 'integer', + 'payment_code' => 'string', + 'payment_name' => 'string', + 'is_success' => 'boolean', + 'request' => 'array', + 'response' => 'array', + 'is_refundable' => 'boolean', + ]); +}); diff --git a/tests/Models/PaymentProfileTest.php b/tests/Models/PaymentProfileTest.php new file mode 100644 index 0000000..3f6b9a1 --- /dev/null +++ b/tests/Models/PaymentProfileTest.php @@ -0,0 +1,80 @@ +paymentProfile = new PaymentProfile(); + $this->customer = Mockery::mock(Customer::class)->makePartial(); +}); + +it('sets profile data and saves the model', function() { + $profileData = ['card_id' => '123', 'customer_id' => '456']; + $this->paymentProfile->setProfileData($profileData); + + expect($this->paymentProfile->profile_data)->toBe($profileData); +}); + +it('returns true if profile data contains required fields', function() { + $this->paymentProfile->profile_data = ['card_id' => '123', 'customer_id' => '456']; + + expect($this->paymentProfile->hasProfileData())->toBeTrue(); +}); + +it('returns false if profile data does not contain required fields', function() { + $this->paymentProfile->profile_data = ['card_id' => '123']; + + expect($this->paymentProfile->hasProfileData())->toBeFalse(); +}); + +it('makes the profile primary and updates other profiles', function() { + $paymentProfile = PaymentProfile::factory()->create([ + 'is_primary' => false, + ]); + + $paymentProfile->makePrimary(); + + expect(PaymentProfile::where('payment_profile_id', $paymentProfile->getKey())->first())->is_primary->toBeTrue(); +}); + +it('returns the primary profile for a customer', function() { + $paymentProfile = PaymentProfile::factory()->create([ + 'customer_id' => 1, + 'is_primary' => true, + ]); + + $this->customer->customer_id = 1; + + expect(PaymentProfile::getPrimary($this->customer)->getKey())->toBe($paymentProfile->getKey()); +}); + +it('returns the first profile if no primary profile exists', function() { + $paymentProfile = PaymentProfile::factory()->create([ + 'customer_id' => 1, + 'is_primary' => false, + ]); + + $this->customer->customer_id = 1; + + expect(PaymentProfile::getPrimary($this->customer)->getKey())->toBe($paymentProfile->getKey()); +}); + +it('returns true if customer has a profile', function() { + PaymentProfile::factory()->create([ + 'customer_id' => 1, + 'is_primary' => false, + ]); + + $this->customer->customer_id = 1; + + expect(PaymentProfile::customerHasProfile($this->customer))->toBeTrue(); +}); + +it('returns false if customer does not have a profile', function() { + $this->customer->customer_id = 1; + + expect(PaymentProfile::customerHasProfile($this->customer))->toBeFalse(); +}); diff --git a/tests/Models/PaymentTest.php b/tests/Models/PaymentTest.php new file mode 100644 index 0000000..9fd51de --- /dev/null +++ b/tests/Models/PaymentTest.php @@ -0,0 +1,184 @@ +payment = Mockery::mock(Payment::class)->makePartial(); + $this->customer = Mockery::mock(Customer::class)->makePartial(); + $this->gatewayManager = Mockery::mock(PaymentGateways::class); +}); + +it('returns dropdown options for enabled payments', function() { + $this->payment->shouldReceive('whereIsEnabled->dropdown') + ->with('name', 'code') + ->andReturn(['option1' => 'value1']); + + $result = $this->payment->getDropdownOptions(); + + expect($result)->toBe(['option1' => 'value1']); +}); + +it('returns list of enabled payments with descriptions', function() { + $result = Payment::listDropdownOptions(); + + expect($result->toArray())->toBe(['cod' => ['Cash On Delivery', 'Accept cash on delivery during checkout']]); +}); + +it('returns true if onboarding is complete', function() { + Payment::factory()->create(['status' => 1]); + + $result = Payment::onboardingIsComplete(); + + expect($result)->toBeTrue(); +}); + +it('returns false if onboarding is not complete', function() { + Payment::query()->update(['status' => 0]); + + $result = Payment::onboardingIsComplete(); + + expect($result)->toBeFalse(); +}); + +it('lists gateways from gateway manager', function() { + $this->payment->gatewayManager = $this->gatewayManager + ->shouldReceive('listGateways') + ->andReturn(['code1' => ['code' => 'code1', 'name' => 'Gateway 1']]); + + $result = $this->payment->listGateways(); + + expect($result)->toBe([ + 'cod' => 'lang:igniter.payregister::default.cod.text_payment_title', + 'paypalexpress' => 'lang:igniter.payregister::default.paypal.text_payment_title', + 'authorizenetaim' => 'lang:igniter.payregister::default.authorize_net_aim.text_payment_title', + 'stripe' => 'lang:igniter.payregister::default.stripe.text_payment_title', + 'mollie' => 'lang:igniter.payregister::default.mollie.text_payment_title', + 'square' => 'lang:igniter.payregister::default.square.text_payment_title', + ]); +}); + +it('sets code attribute with slug format', function() { + $this->payment->setCodeAttribute('Test Code'); + + expect($this->payment->code)->toBe('test_code'); +}); + +it('purges config fields and returns data', function() { + $paymentMethod = Payment::factory()->create(); + $paymentMethod->applyGatewayClass(); + $paymentMethod->test_field = 'value1'; + + $result = $paymentMethod->purgeConfigFields(); + + expect($result)->toBe(['test_field' => 'value1']) + ->and($paymentMethod->getAttributes())->not->toHaveKey('test_field'); +}); + +it('applies gateway class if class exists', function() { + $this->payment->class_name = TestPayment::class; + $this->payment->shouldReceive('isClassExtendedWith')->with(TestPayment::class)->andReturn(false); + $this->payment->shouldReceive('extendClassWith')->with(TestPayment::class); + + $result = $this->payment->applyGatewayClass(); + + expect($result)->toBeTrue() + ->and($this->payment->class_name)->toBe(TestPayment::class); +}); + +it('does not apply gateway class if class does not exist', function() { + $this->payment->class_name = 'NonExistingClass'; + + $result = $this->payment->applyGatewayClass(); + + expect($result)->toBeFalse() + ->and($this->payment->class_name)->toBeNull(); +}); + +it('finds payment profile for customer', function() { + $this->customer->customer_id = 1; + + $result = $this->payment->findPaymentProfile($this->customer); + + expect($result)->toBeNull(); +}); + +it('returns null if customer is not provided for finding payment profile', function() { + $result = $this->payment->findPaymentProfile(null); + + expect($result)->toBeNull(); +}); + +it('initializes new payment profile for customer', function() { + $this->customer->customer_id = 1; + + $result = $this->payment->initPaymentProfile($this->customer); + + expect($result->customer_id)->toBe(1) + ->and($result->payment_id)->toBe($this->payment->payment_id); +}); + +it('returns true if payment profile exists for customer', function() { + $this->payment->shouldReceive('getGatewayObject->paymentProfileExists')->with($this->customer)->andReturn(true); + + $result = $this->payment->paymentProfileExists($this->customer); + + expect($result)->toBeTrue(); +}); + +it('returns false if payment profile does not exist for customer', function() { + $this->payment->shouldReceive('getGatewayObject->paymentProfileExists')->with($this->customer)->andReturn(false); + $this->payment->shouldReceive('findPaymentProfile')->with($this->customer)->andReturn(null); + + $result = $this->payment->paymentProfileExists($this->customer); + + expect($result)->toBeFalse(); +}); + +it('deletes payment profile for customer', function() { + $profile = Mockery::mock(PaymentProfile::class); + $this->payment->shouldReceive('findPaymentProfile')->once()->with($this->customer)->andReturn($profile); + $this->payment->shouldReceive('getGatewayObject->deletePaymentProfile')->once()->with($this->customer, $profile); + $profile->shouldReceive('delete'); + + $this->payment->deletePaymentProfile($this->customer); +}); + +it('throws exception if payment profile not found for customer', function() { + $gateway = Mockery::mock(TestPayment::class); + $this->payment->shouldReceive('getGatewayObject')->with()->andReturn($gateway); + $this->payment->shouldReceive('findPaymentProfile')->with($this->customer)->andReturn(null); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage(lang('igniter.user::default.customers.alert_customer_payment_profile_not_found')); + + $this->payment->deletePaymentProfile($this->customer); +}); + +it('configures payment model correctly', function() { + $payment = new Payment(); + + expect(class_uses_recursive($payment)) + ->toContain(Defaultable::class) + ->toContain(Purgeable::class) + ->toContain(Sortable::class) + ->toContain(Switchable::class) + ->and($payment->getTable())->toBe('payments') + ->and($payment->getKeyName())->toBe('payment_id') + ->and($payment->timestamps)->toBeTrue() + ->and($payment->getCasts())->toHaveKeys(['data', 'priority']) + ->and($payment->getFillable())->toBe(['name', 'code', 'class_name', 'description', 'data', 'priority', 'status', 'is_default']) + ->and($payment->getMorphClass())->toBe('payments') + ->and($payment->getPurgeableAttributes())->toContain('payment'); +}); diff --git a/tests/Payments/AuthorizeNetAimTest.php b/tests/Payments/AuthorizeNetAimTest.php new file mode 100644 index 0000000..62360ec --- /dev/null +++ b/tests/Payments/AuthorizeNetAimTest.php @@ -0,0 +1,310 @@ +payment = Payment::factory()->create([ + 'class_name' => AuthorizeNetAim::class, + ]); + $this->authorizeNetAim = new AuthorizeNetAim($this->payment); +}); + +it('returns correct payment form view for authorizenet', function() { + expect(AuthorizeNetAim::$paymentFormView)->toBe('igniter.payregister::_partials.authorizenetaim.payment_form'); +}); + +it('returns correct fields config for authorizenet', function() { + expect($this->authorizeNetAim->defineFieldsConfig())->toBe('igniter.payregister::/models/authorizenetaim'); +}); + +it('returns correct endpoint for authorizenet test mode', function() { + $this->payment->transaction_mode = 'test'; + + $result = $this->authorizeNetAim->getEndPoint(); + + expect($result)->toBe('https://jstest.authorize.net'); +}); + +it('returns correct endpoint for authorizenet live mode', function() { + $this->payment->transaction_mode = 'live'; + + $result = $this->authorizeNetAim->getEndPoint(); + + expect($result)->toBe('https://js.authorize.net'); +}); + +it('returns correct authorizenet model value', function($attribute, $methodName, $value, $returnValue) { + $this->payment->$attribute = $value; + + expect($this->authorizeNetAim->$methodName())->toBe($returnValue); +})->with([ + ['client_key', 'getClientKey', 'client123', 'client123'], + ['transaction_key', 'getTransactionKey', 'key123', 'key123'], + ['api_login_id', 'getApiLoginID', 'login123', 'login123'], + ['transaction_mode', 'isTestMode', 'live', false], + ['transaction_mode', 'isTestMode', 'test', true], + ['transaction_type', 'shouldAuthorizePayment', 'auth_only', true], + ['transaction_type', 'shouldAuthorizePayment', 'auth_capture', false], +]); + +it('adds JavaScript file to the controller', function() { + $controller = Mockery::mock(MainController::class); + + $controller + ->shouldReceive('addJs') + ->with('igniter.payregister::/js/authorizenetaim.js', 'authorizenetaim-js') + ->once(); + + $this->authorizeNetAim->beforeRenderPaymentForm($this->authorizeNetAim, $controller); +}); + +it('processes authorizenet payment form and logs successful payment', function() { + $messageAType = (new MessageAType())->setCode('1')->setDescription('Success'); + $response = Mockery::mock(TransactionResponseType::class); + $response->shouldReceive('getResponseCode')->andReturn('1')->twice(); + $response->shouldReceive('getMessages')->andReturn([$messageAType])->twice(); + $response->shouldReceive('getTransId')->andReturn('12345')->once(); + $response->shouldReceive('getAccountNumber')->andReturn('****1111')->once(); + $response->shouldReceive('getAccountType')->andReturn('Visa')->once(); + $response->shouldReceive('getAuthCode')->andReturn('auth123')->once(); + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $authorizeNetAim->shouldReceive('createAcceptPayment')->andReturn($response)->once(); + $authorizeNetAim->shouldReceive('shouldAuthorizePayment')->andReturnFalse()->once(); + + $order = Mockery::mock(Order::class)->makePartial(); + $order->payment_method = $this->payment; + $order->order_total = 100; + $order->shouldReceive('logPaymentAttempt')->with('Payment successful', 1, Mockery::any(), Mockery::any(), true)->once(); + $order->shouldReceive('updateOrderStatus')->once(); + $order->shouldReceive('markAsPaymentProcessed')->once(); + $data = ['authorizenetaim_DataDescriptor' => 'descriptor', 'authorizenetaim_DataValue' => 'value']; + + $this->payment->applyGatewayClass(); + + $authorizeNetAim->processPaymentForm($data, $this->payment, $order); +}); + +it('throws exception if authorizenet payment form processing fails', function() { + $order = Mockery::mock(Order::class)->makePartial(); + $order->shouldReceive('logPaymentAttempt')->with('Payment error -> Payment error', 0, Mockery::any())->once(); + $order->payment_method = $this->payment; + $order->order_total = 100; + $data = ['authorizenetaim_DataDescriptor' => 'descriptor', 'authorizenetaim_DataValue' => 'value']; + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $authorizeNetAim->shouldReceive('createAcceptPayment')->andThrow(new Exception('Payment error'))->once(); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later.'); + + $this->payment->applyGatewayClass(); + + $authorizeNetAim->processPaymentForm($data, $this->payment, $order); +}); + +it('processes authorizenet payment form and logs failed payment', function() { + $messageAType = (new MessageAType())->setCode('2')->setDescription('Declined'); + $response = Mockery::mock(TransactionResponseType::class); + $response->shouldReceive('getResponseCode')->andReturn('2')->twice(); + $response->shouldReceive('getMessages')->andReturn([$messageAType])->atMost(5); + $response->shouldReceive('getTransId')->andReturn('12345')->once(); + $response->shouldReceive('getAccountNumber')->andReturn('****1111')->once(); + $response->shouldReceive('getAccountType')->andReturn('Visa')->once(); + $response->shouldReceive('getAuthCode')->andReturn('auth123')->once(); + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $authorizeNetAim->shouldReceive('createAcceptPayment')->andReturn($response)->once(); + + $order = Mockery::mock(Order::class)->makePartial(); + $order->order_id = 123; + $order->payment_method = $this->payment; + $order->order_total = 100; + $order->shouldReceive('logPaymentAttempt')->with('Payment unsuccessful -> Declined', 0, Mockery::any(), Mockery::any())->once(); + $order->shouldNotReceive('updateOrderStatus'); + $order->shouldNotReceive('markAsPaymentProcessed'); + $data = ['authorizenetaim_DataDescriptor' => 'descriptor', 'authorizenetaim_DataValue' => 'value']; + + $this->payment->applyGatewayClass(); + + $authorizeNetAim->processPaymentForm($data, $this->payment, $order); +}); + +it('processes authorizenet full refund successfully', function() { + $messageAType = (new MessageAType())->setCode('2')->setDescription('Declined'); + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->shouldReceive('markAsRefundProcessed')->once(); + $paymentLog->refunded_at = null; + $paymentLog->response = ['status' => '1', 'id' => '12345', 'card_holder' => '****1111']; + + $order = Mockery::mock(Order::class)->makePartial(); + $order->shouldReceive('logPaymentAttempt'); + $order->order_total = 100; + + $response = Mockery::mock(TransactionResponseType::class); + $response->shouldReceive('getResponseCode')->andReturn('1')->once(); + $response->shouldReceive('getTransId')->andReturn('54321')->once(); + $response->shouldReceive('getMessages')->andReturn([$messageAType])->twice(); + $response->shouldReceive('getAccountNumber')->andReturn('****1111')->once(); + $response->shouldReceive('getAccountType')->andReturn('Visa')->once(); + $response->shouldReceive('getAuthCode')->andReturn('auth123')->once(); + + $data = ['refund_type' => 'full']; + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $authorizeNetAim->shouldReceive('createRefundPayment')->andReturn($response)->once(); + + $authorizeNetAim->processRefundForm($data, $order, $paymentLog); +}); + +it('authorizenet: throws exception if refund amount exceeds order total', function() { + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->refunded_at = null; + $paymentLog->response = ['status' => '1', 'id' => '12345', 'card_holder' => '****1111']; + + $order = Mockery::mock(Order::class)->makePartial(); + $order->order_total = 100; + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $data = ['refund_type' => 'partial', 'refund_amount' => 150]; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Refund amount should be be less than or equal to the order total'); + + $authorizeNetAim->processRefundForm($data, $order, $paymentLog); +}); + +it('authorizenet: captures authorized payment successfully', function() { + Event::fake(); + + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->response = ['id' => '12345']; + + $order = Mockery::mock(Order::class)->makePartial(); + $order->hash = 'order_hash'; + $order->shouldReceive('logPaymentAttempt')->once(); + $order->shouldReceive('payment_logs->firstWhere') + ->with('is_success', true) + ->andReturn($paymentLog) + ->once(); + + $messageAType = (new MessageAType())->setCode('1')->setDescription('Success'); + $response = Mockery::mock(TransactionResponseType::class); + $response->shouldReceive('getResponseCode')->andReturn('1')->twice(); + $response->shouldReceive('getTransId')->andReturn('54321')->once(); + $response->shouldReceive('getMessages')->andReturn([$messageAType])->twice(); + $response->shouldReceive('getAccountNumber')->andReturn('****1111')->once(); + $response->shouldReceive('getAccountType')->andReturn('Visa')->once(); + $response->shouldReceive('getAuthCode')->andReturn('auth123')->once(); + + $request = new CreateTransactionRequest; + $request->setMerchantAuthentication(new MerchantAuthenticationType); + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $authorizeNetAim->shouldReceive('createClient->createTransactionRequest')->andReturn($request)->once(); + $authorizeNetAim->shouldReceive('createClient->createTransaction')->andReturn($response)->once(); + + $authorizeNetAim->captureAuthorizedPayment($order); + + Event::assertDispatched('payregister.authorizenetaim.extendCaptureRequest'); +}); + +it('authorizenet: throws exception if no successful transaction to capture', function() { + $order = Mockery::mock(Order::class)->makePartial(); + $order->shouldReceive('payment_logs->firstWhere') + ->with('is_success', true) + ->andReturnNull() + ->once(); + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No successful transaction to capture'); + + $authorizeNetAim->captureAuthorizedPayment($order); +}); + +it('authorizenet: cancels authorized payment successfully', function() { + Event::fake(); + + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->is_success = true; + $paymentLog->response = ['id' => '12345']; + + $order = Mockery::mock(Order::class)->makePartial(); + $order->hash = 'order_hash'; + $order->shouldReceive('logPaymentAttempt'); + $order->shouldReceive('payment_logs->firstWhere') + ->with('is_success', true) + ->andReturn($paymentLog) + ->once(); + + $messageAType = (new MessageAType())->setCode('1')->setDescription('Success'); + $response = Mockery::mock(TransactionResponseType::class); + $response->shouldReceive('getResponseCode')->andReturn('1')->twice(); + $response->shouldReceive('getTransId')->andReturn('54321')->once(); + $response->shouldReceive('getMessages')->andReturn([$messageAType])->twice(); + $response->shouldReceive('getAccountNumber')->andReturn('****1111')->once(); + $response->shouldReceive('getAccountType')->andReturn('Visa')->once(); + $response->shouldReceive('getAuthCode')->andReturn('auth123')->once(); + + $request = new CreateTransactionRequest; + $request->setMerchantAuthentication(new MerchantAuthenticationType); + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $authorizeNetAim->shouldReceive('createClient->createTransactionRequest')->andReturn($request)->once(); + $authorizeNetAim->shouldReceive('createClient->createTransaction')->andReturn($response)->once(); + + $authorizeNetAim->cancelAuthorizedPayment($order); + + Event::assertDispatched('payregister.authorizenetaim.extendCancelRequest'); +}); + +it('authorizenet: throws exception if no successful transaction to cancel', function() { + $order = Mockery::mock(Order::class)->makePartial(); + $order->shouldReceive('payment_logs->firstWhere') + ->with('is_success', true) + ->andReturnNull() + ->once(); + + $authorizeNetAim = Mockery::mock(AuthorizeNetAim::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No successful transaction to capture'); + + $authorizeNetAim->cancelAuthorizedPayment($order); +}); + diff --git a/tests/Payments/CodTest.php b/tests/Payments/CodTest.php new file mode 100644 index 0000000..b2e09ba --- /dev/null +++ b/tests/Payments/CodTest.php @@ -0,0 +1,45 @@ +payment = Mockery::mock(Payment::class)->makePartial(); + $this->order = Mockery::mock(Order::class)->makePartial(); + $this->order->payment_method = $this->payment; + $this->cod = new Cod(); +}); + +it('returns correct payment form view', function() { + expect(Cod::$paymentFormView)->toBe('igniter.payregister::_partials.cod.payment_form'); +}); + +it('returns correct fields config', function() { + expect($this->cod->defineFieldsConfig())->toBe('igniter.payregister::/models/cod'); +}); + +it('processes payment form and updates order status', function() { + $this->payment->order_status = 'processed'; + $this->payment->order_total = 100; + $this->order->order_total = 100; + $this->order->shouldReceive('updateOrderStatus')->with('processed', ['notify' => false])->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + + $this->cod->processPaymentForm([], $this->payment, $this->order); +}); + +it('throws exception if applicable fee validation fails', function() { + $this->payment->order_status = 'processed'; + $this->payment->order_total = 200; + $this->order->order_total = 100; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('You need to spend £200.00 or more to pay with'); + + $this->cod->processPaymentForm([], $this->payment, $this->order); +}); diff --git a/tests/Payments/Fixtures/TestPayment.php b/tests/Payments/Fixtures/TestPayment.php new file mode 100644 index 0000000..7174c6b --- /dev/null +++ b/tests/Payments/Fixtures/TestPayment.php @@ -0,0 +1,25 @@ + 'processReturnUrl', + ]; + } + + public function processReturnUrl() + { + return 'test_endpoint'; + } +} diff --git a/tests/Payments/Fixtures/TestPaymentWithAuthorized.php b/tests/Payments/Fixtures/TestPaymentWithAuthorized.php new file mode 100644 index 0000000..07fd8d2 --- /dev/null +++ b/tests/Payments/Fixtures/TestPaymentWithAuthorized.php @@ -0,0 +1,32 @@ +payment = Payment::factory()->create([ + 'class_name' => Mollie::class, + ]); + $this->paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $this->order = Mockery::mock(Order::class)->makePartial(); + $this->order->payment_method = $this->payment; + $this->mollie = new Mollie($this->payment); +}); + +it('returns correct payment form view for mollie', function() { + expect(Mollie::$paymentFormView)->toBe('igniter.payregister::_partials.mollie.payment_form'); +}); + +it('returns correct fields config for mollie', function() { + expect($this->mollie->defineFieldsConfig())->toBe('igniter.payregister::/models/mollie'); +}); + +it('registers correct entry points for mollie', function() { + $entryPoints = $this->mollie->registerEntryPoints(); + + expect($entryPoints)->toBe([ + 'mollie_return_url' => 'processReturnUrl', + 'mollie_notify_url' => 'processNotifyUrl', + ]); +}); + +it('returns true if in mollie test mode', function() { + $this->payment->transaction_mode = 'test'; + expect($this->mollie->isTestMode())->toBeTrue(); +}); + +it('returns false if not in mollie test mode', function() { + $this->payment->transaction_mode = 'live'; + expect($this->mollie->isTestMode())->toBeFalse(); +}); + +it('returns mollie test API key in test mode', function() { + $this->payment->transaction_mode = 'test'; + $this->payment->test_api_key = 'test_key'; + expect($this->mollie->getApiKey())->toBe('test_key'); +}); + +it('returns mollie live API key in live mode', function() { + $this->payment->transaction_mode = 'live'; + $this->payment->live_api_key = 'live_key'; + expect($this->mollie->getApiKey())->toBe('live_key'); +}); + +it('processes mollie payment form and redirects to checkout url', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->order_total = 100; + + $paymentProfile = Mockery::mock(PaymentProfile::class)->makePartial(); + $paymentProfile->profile_data = ['card_id' => '123', 'customer_id' => '456']; + + $molliePayment = Mockery::mock(MolliePayment::class); + $molliePayment->shouldReceive('isOpen')->andReturn(true)->once(); + $molliePayment->shouldReceive('getCheckoutUrl')->andReturn('http://checkout.url')->once(); + $paymentEndpoint = Mockery::mock(PaymentEndpoint::class); + $paymentEndpoint->shouldReceive('create')->andReturn($molliePayment)->once(); + $mollieClient = Mockery::mock(MollieApiClient::class)->makePartial(); + $mollieClient->setApiKey('test_'.str_random(30)); + $mollieClient->payments = $paymentEndpoint; + + $mollie = Mockery::mock(Mollie::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $mollie->shouldReceive('validateApplicableFee')->once(); + $mollie->shouldReceive('updatePaymentProfile')->andReturn($paymentProfile)->once(); + $mollie->shouldReceive('createClient')->andReturn($mollieClient)->once(); + + $response = $mollie->processPaymentForm([], $this->payment, $this->order); + + expect($response->getTargetUrl())->toBe('http://checkout.url'); +}); + +it('throws exception if mollie payment creation fails', function() { + $this->order->shouldReceive('logPaymentAttempt')->with('Payment error -> Payment error', 0, Mockery::any(), Mockery::any())->once(); + + $payment = Mockery::mock(MolliePayment::class); + $payment->shouldReceive('isOpen')->andReturn(false)->once(); + $payment->shouldReceive('getMessage')->andReturn('Payment error')->once(); + $paymentEndpoint = Mockery::mock(PaymentEndpoint::class); + $paymentEndpoint->shouldReceive('create')->andReturn($payment)->once(); + + $mollieClient = Mockery::mock(MollieApiClient::class)->makePartial(); + $mollieClient->setApiKey('test_'.str_random(30)); + $mollieClient->payments = $paymentEndpoint; + + $mollie = Mockery::mock(Mollie::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $mollie->shouldReceive('createClient')->andReturn($mollieClient); + $mollie->shouldReceive('validateApplicableFee')->once(); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later.'); + + $mollie->processPaymentForm([], $this->payment, $this->order); +}); + +it('processes mollie return url and updates order status', function() { + request()->merge([ + 'redirect' => 'http://redirect.url', + 'cancel' => 'http://cancel.url', + ]); + + $this->payment->applyGatewayClass(); + $this->order->hash = 'order_hash'; + $this->order->order_id = 1; + $this->order->shouldReceive('logPaymentAttempt')->with('Payment successful', 1, Mockery::any(), Mockery::any(), true)->once(); + $this->order->shouldReceive('updateOrderStatus')->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false); + + $molliePayment = Mockery::mock(MolliePayment::class); + $molliePayment->shouldReceive('isPaid')->andReturn(true)->once(); + $molliePayment->metadata = ['order_id' => 1]; + $paymentEndpoint = Mockery::mock(PaymentEndpoint::class); + $paymentEndpoint->shouldReceive('get')->andReturn($molliePayment)->once(); + + $mollieClient = Mockery::mock(MollieApiClient::class)->makePartial(); + $mollieClient->setApiKey('test_'.str_random(30)); + $mollieClient->payments = $paymentEndpoint; + + $mollie = Mockery::mock(Mollie::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $mollie->shouldReceive('createClient')->andReturn($mollieClient); + $mollie->shouldReceive('createOrderModel->whereHash->first')->andReturn($this->order); + + $response = $mollie->processReturnUrl(['order_hash']); + + expect($response->getTargetUrl())->toContain('http://redirect.url'); +}); + +it('throws exception if no order found in mollie return url', function() { + request()->merge([ + 'redirect' => 'http://redirect.url', + 'cancel' => 'http://cancel.url', + ]); + + $response = $this->mollie->processReturnUrl(['invalid_hash']); + + expect($response->getTargetUrl())->toContain('http://cancel.url') + ->and(flash()->messages()->first())->message->toBe('No order found'); +}); + +it('processes mollie notify url and updates order status', function() { + request()->merge([ + 'id' => 'payment_id', + ]); + + $this->payment->applyGatewayClass(); + $this->order->hash = 'order_hash'; + $this->order->order_id = 1; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->order->shouldReceive('updateOrderStatus')->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false); + + $molliePayment = Mockery::mock(MolliePayment::class); + $molliePayment->shouldReceive('isPaid')->andReturn(true)->once(); + $paymentEndpoint = Mockery::mock(PaymentEndpoint::class); + $paymentEndpoint->shouldReceive('get')->andReturn($molliePayment)->once(); + $mollieClient = Mockery::mock(MollieApiClient::class)->makePartial(); + $mollieClient->setApiKey('test_'.str_random(30)); + $mollieClient->payments = $paymentEndpoint; + + $mollie = Mockery::mock(Mollie::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $mollie->shouldReceive('createClient')->andReturn($mollieClient); + $mollie->shouldReceive('createOrderModel->whereHash->first')->andReturn($this->order); + + $response = $mollie->processNotifyUrl(['order_hash']); + + expect($response->getData())->success->toBe(true); +}); + +it('throws exception if no order found in mollie notify url', function() { + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No order found'); + + $mollie = Mockery::mock(Mollie::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $mollie->shouldReceive('createOrderModel->whereHash->first')->andReturnNull(); + + $this->mollie->processNotifyUrl(['invalid_hash']); +}); + +it('processes mollie refund form and logs refund attempt', function() { + $this->paymentLog->refunded_at = null; + $this->paymentLog->response = ['status' => 'paid', 'id' => 'payment_id']; + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->paymentLog->shouldReceive('markAsRefundProcessed')->once(); + + $molliePayment = Mockery::mock(MolliePayment::class); + $molliePayment->shouldReceive('refund')->andReturn((object)[ + 'id' => 'refund_id', + 'status' => 'refunded', + 'amount' => (object)['value' => '100.00', 'currency' => 'GBP'], + ])->once(); + $paymentEndpoint = Mockery::mock(PaymentEndpoint::class); + $paymentEndpoint->shouldReceive('get')->andReturn($molliePayment)->once(); + $mollieClient = Mockery::mock(MollieApiClient::class)->makePartial(); + $mollieClient->setApiKey('test_'.str_random(30)); + $mollieClient->payments = $paymentEndpoint; + + $mollie = Mockery::mock(Mollie::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $mollie->shouldReceive('createClient')->andReturn($mollieClient); + + $mollie->processRefundForm(['refund_type' => 'full'], $this->order, $this->paymentLog); +}); + +it('throws exception if no mollie charge to refund', function() { + $this->paymentLog->refunded_at = null; + $this->paymentLog->response = ['status' => 'not_paid']; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No charge to refund'); + + $this->mollie->processRefundForm(['refund_type' => 'full'], $this->order, $this->paymentLog); +}); diff --git a/tests/Payments/PaypalExpressTest.php b/tests/Payments/PaypalExpressTest.php new file mode 100644 index 0000000..2127a28 --- /dev/null +++ b/tests/Payments/PaypalExpressTest.php @@ -0,0 +1,227 @@ +payment = Payment::factory()->create([ + 'class_name' => PaypalExpress::class, + ]); + $this->paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $this->order = Mockery::mock(Order::class)->makePartial(); + $this->order->payment_method = $this->payment; + $this->paypalExpress = new PaypalExpress($this->payment); +}); + +it('returns correct payment form view for paypal express', function() { + expect(PaypalExpress::$paymentFormView)->toBe('igniter.payregister::_partials.paypalexpress.payment_form'); +}); + +it('returns correct fields config for paypal express', function() { + expect($this->paypalExpress->defineFieldsConfig())->toBe('igniter.payregister::/models/paypalexpress'); +}); + +it('registers correct entry points for paypal express', function() { + $entryPoints = $this->paypalExpress->registerEntryPoints(); + + expect($entryPoints)->toBe([ + 'paypal_return_url' => 'processReturnUrl', + 'paypal_cancel_url' => 'processCancelUrl', + ]); +}); + +it('returns true if in sandbox mode for paypal express', function() { + $this->payment->api_mode = 'sandbox'; + + expect($this->paypalExpress->isSandboxMode())->toBeTrue(); +}); + +it('returns false if not in sandbox mode for paypal express', function() { + $this->payment->api_mode = 'live'; + + expect($this->paypalExpress->isSandboxMode())->toBeFalse(); +}); + +it('returns sandbox API username in sandbox mode for paypal express', function() { + $this->payment->api_mode = 'sandbox'; + $this->payment->api_sandbox_user = 'sandbox_user'; + + expect($this->paypalExpress->getApiUsername())->toBe('sandbox_user'); +}); + +it('returns live API username in live mode for paypal express', function() { + $this->payment->api_mode = 'live'; + $this->payment->api_user = 'live_user'; + + expect($this->paypalExpress->getApiUsername())->toBe('live_user'); +}); + +it('returns sandbox API password in sandbox mode for paypal express', function() { + $this->payment->api_mode = 'sandbox'; + $this->payment->api_sandbox_pass = 'sandbox_pass'; + + expect($this->paypalExpress->getApiPassword())->toBe('sandbox_pass'); +}); + +it('returns live API password in live mode for paypal express', function() { + $this->payment->api_mode = 'live'; + $this->payment->api_pass = 'live_pass'; + + expect($this->paypalExpress->getApiPassword())->toBe('live_pass'); +}); + +it('returns AUTHORIZE when api_action is authorization for paypal express', function() { + $this->payment->api_action = 'authorization'; + + expect($this->paypalExpress->getTransactionMode())->toBe('AUTHORIZE'); +}); + +it('returns CAPTURE when api_action is not authorization for paypal express', function() { + $this->payment->api_action = 'capture'; + + expect($this->paypalExpress->getTransactionMode())->toBe('CAPTURE'); +}); + +it('processes paypal express payment form and redirects to payer action url', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->order_total = 100; + + $response = Mockery::mock(Response::class); + $response->shouldReceive('ok')->andReturn(true); + $response->shouldReceive('json')->with('links', [])->andReturn([['rel' => 'payer-action', 'href' => 'http://payer.action.url']]); + $paypalExpress = Mockery::mock(PaypalExpress::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $paypalExpress->shouldReceive('getTransactionMode')->andReturn('AUTHORIZE')->once(); + $paypalExpress->shouldReceive('createClient->createOrder')->andReturn($response); + + $result = $paypalExpress->processPaymentForm([], $this->payment, $this->order); + + expect($result->getTargetUrl())->toBe('http://payer.action.url'); +}); + +it('throws exception if paypal express payment creation fails', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt') + ->with('Payment error -> Payment error', 0, Mockery::any(), Mockery::any()) + ->once(); + + $paypalExpress = Mockery::mock(PaypalExpress::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $paypalExpress->shouldReceive('validateApplicableFee')->once(); + $paypalExpress->shouldReceive('getTransactionMode')->andReturn('AUTHORIZE')->once(); + $paypalExpress->shouldReceive('createClient->createOrder')->andThrow(new Exception('Payment error')); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later.'); + + $paypalExpress->processPaymentForm([], $this->payment, $this->order); +}); + +it('processes paypal express return url and updates order status', function(string $transactionMode) { + request()->merge(['token' => 'test_token']); + + $this->payment->applyGatewayClass(); + $this->order->hash = 'order_hash'; + $this->order->order_id = 1; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->order->shouldReceive('updateOrderStatus')->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false); + + $response = Mockery::mock(Response::class); + $response->shouldReceive('json')->withNoArgs()->andReturn([]); + if ($transactionMode === 'CAPTURE') { + $response->shouldReceive('json')->with('purchase_units.0.payments.captures.0.status')->andReturn('COMPLETED'); + } else { + $response->shouldReceive('json')->with('purchase_units.0.payments.authorizations.0.status')->andReturn('CREATED'); + } + + $paypalExpress = Mockery::mock(PaypalExpress::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $paypalExpress->shouldReceive('createClient->getOrder')->andReturn(['status' => 'APPROVED', 'intent' => $transactionMode]); + $paypalExpress->shouldReceive('createClient->captureOrder')->andReturn($response); + $paypalExpress->shouldReceive('createClient->authorizeOrder')->andReturn($response); + $paypalExpress->shouldReceive('createOrderModel->whereHash->first')->andReturn($this->order); + + $result = $paypalExpress->processReturnUrl(['order_hash']); + + expect($result->getTargetUrl())->toContain('http://localhost/checkout'); +})->with([ + ['CAPTURE'], + ['AUTHORIZE'], +]); + +it('throws exception if no order found in paypal express return url', function() { + request()->merge([ + 'redirect' => 'http://redirect.url', + 'cancel' => 'http://cancel.url', + ]); + + $response = $this->paypalExpress->processReturnUrl(['invalid_hash']); + + expect($response->getTargetUrl())->toContain('http://cancel.url') + ->and(flash()->messages()->first())->message->toBe('No order found'); +}); + +it('processes paypal express cancel url and logs payment attempt', function() { + $this->payment->applyGatewayClass(); + $this->order->hash = 'order_hash'; + $this->order->shouldReceive('logPaymentAttempt')->once(); + + $paypalExpress = Mockery::mock(PaypalExpress::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $paypalExpress->shouldReceive('createOrderModel->whereHash->first')->andReturn($this->order); + + $result = $paypalExpress->processCancelUrl(['order_hash']); + + expect($result->getTargetUrl())->toContain('http://localhost/checkout'); +}); + +it('throws exception if no order found in paypal express cancel url', function() { + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No order found'); + + $this->paypalExpress->processCancelUrl(['invalid_hash']); +}); + +it('processes paypal express refund form and logs refund attempt', function() { + $this->paymentLog->refunded_at = null; + $this->paymentLog->response = ['purchase_units' => [['payments' => ['captures' => [['status' => 'COMPLETED', 'id' => 'payment_id']]]]]]; + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->paymentLog->shouldReceive('markAsRefundProcessed')->once(); + + $response = Mockery::mock(Response::class); + $response->shouldReceive('json')->with('id')->andReturn('refund_id'); + $response->shouldReceive('json')->withNoArgs()->andReturn([]); + $paypalExpress = Mockery::mock(PaypalExpress::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $paypalExpress->shouldReceive('createClient->refundPayment')->andReturn($response); + + $paypalExpress->processRefundForm(['refund_type' => 'full'], $this->order, $this->paymentLog); +}); + +it('throws exception if no paypal express charge to refund', function() { + $this->paymentLog->refunded_at = null; + $this->paymentLog->response = ['purchase_units' => [['payments' => ['captures' => [['status' => 'not_completed']]]]]]; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No charge to refund'); + + $this->paypalExpress->processRefundForm(['refund_type' => 'full'], $this->order, $this->paymentLog); +}); diff --git a/tests/Payments/SquareTest.php b/tests/Payments/SquareTest.php new file mode 100644 index 0000000..28f0cb4 --- /dev/null +++ b/tests/Payments/SquareTest.php @@ -0,0 +1,343 @@ +payment = Payment::factory()->create([ + 'class_name' => PaypalExpress::class, + ]); + $this->paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $this->order = Mockery::mock(Order::class)->makePartial(); + $this->order->payment_method = $this->payment; + $this->square = new Square($this->payment); +}); + +it('returns correct payment form view for square', function() { + expect(Square::$paymentFormView)->toBe('igniter.payregister::_partials.square.payment_form'); +}); + +it('returns correct fields config for square', function() { + expect($this->square->defineFieldsConfig())->toBe('igniter.payregister::/models/square'); +}); + +it('returns hidden fields for square', function() { + $hiddenFields = $this->square->getHiddenFields(); + expect($hiddenFields)->toBe([ + 'square_card_nonce' => '', + 'square_card_token' => '', + ]); +}); + +it('returns true if in test mode for square', function() { + $this->payment->transaction_mode = 'test'; + expect($this->square->isTestMode())->toBeTrue(); +}); + +it('returns false if not in test mode for square', function() { + $this->payment->transaction_mode = 'live'; + expect($this->square->isTestMode())->toBeFalse(); +}); + +it('returns test app id in test mode for square', function() { + $this->payment->transaction_mode = 'test'; + $this->payment->test_app_id = 'test_app_id'; + expect($this->square->getAppId())->toBe('test_app_id'); +}); + +it('returns live app id in live mode for square', function() { + $this->payment->transaction_mode = 'live'; + $this->payment->live_app_id = 'live_app_id'; + expect($this->square->getAppId())->toBe('live_app_id'); +}); + +it('returns test access token in test mode for square', function() { + $this->payment->transaction_mode = 'test'; + $this->payment->test_access_token = 'test_access_token'; + expect($this->square->getAccessToken())->toBe('test_access_token'); +}); + +it('returns live access token in live mode for square', function() { + $this->payment->transaction_mode = 'live'; + $this->payment->live_access_token = 'live_access_token'; + expect($this->square->getAccessToken())->toBe('live_access_token'); +}); + +it('returns test location id in test mode for square', function() { + $this->payment->transaction_mode = 'test'; + $this->payment->test_location_id = 'test_location_id'; + expect($this->square->getLocationId())->toBe('test_location_id'); +}); + +it('returns live location id in live mode for square', function() { + $this->payment->transaction_mode = 'live'; + $this->payment->live_location_id = 'live_location_id'; + expect($this->square->getLocationId())->toBe('live_location_id'); +}); + +it('adds correct js files in test mode for square', function() { + $this->payment->transaction_mode = 'test'; + + $controller = Mockery::mock(MainController::class); + $controller->shouldReceive('addJs')->with('https://sandbox.web.squarecdn.com/v1/square.js', 'square-js')->once(); + $controller->shouldReceive('addJs')->with('igniter.payregister::/js/process.square.js', 'process-square-js')->once(); + + $this->square->beforeRenderPaymentForm($this->square, $controller); +}); + +it('adds correct js files in live mode for square', function() { + $this->payment->transaction_mode = 'live'; + + $controller = Mockery::mock(MainController::class); + $controller->shouldReceive('addJs')->with('https://web.squarecdn.com/v1/square.js', 'square-js')->once(); + $controller->shouldReceive('addJs')->with('igniter.payregister::/js/process.square.js', 'process-square-js')->once(); + + $this->square->beforeRenderPaymentForm($this->square, $controller); +}); + +it('returns true for completesPaymentOnClient for square', function() { + expect($this->square->completesPaymentOnClient())->toBeTrue(); +}); + +it('processes square payment form and returns success', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('isSuccess')->andReturn(true); + $response->shouldReceive('getResult')->andReturn(['payment' => 'success']); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('createPayment')->andReturn($response); + + $square->processPaymentForm(['square_card_nonce' => 'nonce'], $this->payment, $this->order); +}); + +it('processes square payment form with payment profile and returns success', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + $this->order->shouldReceive('updateOrderStatus')->once(); + $paymentProfile = Mockery::mock(PaymentProfile::class)->makePartial(); + $paymentProfile->profile_data = ['card_id' => '123', 'customer_id' => '456']; + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('isSuccess')->andReturn(true); + $response->shouldReceive('getResult')->andReturn(['payment' => 'success']); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('createPayment')->andReturn($response); + $square->shouldReceive('updatePaymentProfile')->andReturn($paymentProfile); + + $square->processPaymentForm([ + 'create_payment_profile' => 1, + 'square_card_nonce' => 'nonce', + ], $this->payment, $this->order); +}); + +it('throws exception if square payment creation fails', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt')->with('Payment error -> Payment error', 0, Mockery::any(), Mockery::any())->once(); + + $errorMock = Mockery::mock(Error::class); + $errorMock->shouldReceive('getDetail')->andReturn('Payment error'); + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('isSuccess')->andReturn(false); + $response->shouldReceive('getErrors')->andReturn([$errorMock]); + $response->shouldReceive('getResult')->andReturn([]); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('createPayment')->andReturn($response); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later'); + + $square->processPaymentForm(['square_card_nonce' => 'nonce'], $this->payment, $this->order); +}); + +it('processes square refund form and logs refund attempt', function() { + $this->paymentLog->refunded_at = null; + $this->paymentLog->response = ['payment' => ['status' => 'COMPLETED', 'id' => 'payment_id']]; + $this->order->order_total = 100; + $this->order->shouldReceive('logPaymentAttempt')->with(Mockery::type('string'), 1, Mockery::any(), Mockery::any())->once(); + $this->paymentLog->shouldReceive('markAsRefundProcessed')->once(); + + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('isSuccess')->andReturn(true); + $response->shouldReceive('getResult')->andReturn(['id' => 'refund_id']); + $refundsApi = Mockery::mock(RefundsApi::class); + $refundsApi->shouldReceive('refundPayment')->andReturn($response)->once(); + $squareClient = Mockery::mock(SquareClient::class); + $squareClient->shouldReceive('getRefundsApi')->andReturn($refundsApi); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('createClient')->andReturn($squareClient); + + $square->processRefundForm(['refund_type' => 'full'], $this->order, $this->paymentLog); +}); + +it('throws exception if no square charge to refund', function() { + $this->paymentLog->refunded_at = null; + $this->paymentLog->response = ['payment' => ['status' => 'not_completed']]; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No charge to refund'); + + $this->square->processRefundForm(['refund_type' => 'full'], $this->order, $this->paymentLog); +}); + +it('creates payment successfully from square payment profile', function() { + PaymentProfile::factory()->create([ + 'payment_id' => $this->payment->getKey(), + 'profile_data' => ['card_id' => 'card123', 'customer_id' => 'cust123'], + ]); + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer_name = 'John Doe'; + $this->order->customer->customer_id = 1; + $this->order->shouldReceive('logPaymentAttempt')->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + $this->order->shouldReceive('updateOrderStatus')->once(); + + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('isSuccess')->andReturn(true); + $response->shouldReceive('getResult')->andReturn(['payment' => 'success']); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('getHostObject')->andReturn($this->payment); + $square->shouldReceive('createPayment')->andReturn($response); + + $square->payFromPaymentProfile($this->order, []); +}); + +it('throws exception if square payment profile not found', function() { + $this->order->customer = null; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Payment profile not found'); + + $this->square->payFromPaymentProfile($this->order, []); +}); + +it('throws exception if square payment profile has no data', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer->customer_id = 1; + PaymentProfile::factory()->create([ + 'payment_id' => $this->payment->getKey(), + ]); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Payment profile not found'); + + $this->square->payFromPaymentProfile($this->order, []); +}); + +it('creates new square payment profile if none exists', function() { + $data = ['square_card_nonce' => 'nonce']; + $this->order->customer = $customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer->customer_id = 1; + + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('getCustomer->getId')->andReturn('cust123'); + $response->shouldReceive('getCustomer->getReferenceId')->andReturn('ref123'); + $response->shouldReceive('getCard->getId')->andReturn('card123'); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('getHostObject')->andReturn($this->payment); + $square->shouldReceive('createOrFetchCustomer')->andReturn($response); + $square->shouldReceive('createOrFetchCard')->andReturn($response); + $square->shouldReceive('updatePaymentProfileData')->with(Mockery::any(), [ + 'customer_id' => 'cust123', + 'card_id' => 'card123', + ], Mockery::any())->once(); + + $square->updatePaymentProfile($customer, $data); +}); + +it('updates existing square payment profile', function() { + $data = ['square_card_nonce' => 'nonce']; + $this->order->customer = $customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer->customer_id = 1; + $profile = PaymentProfile::factory()->create([ + 'payment_id' => $this->payment->getKey(), + 'profile_data' => ['card_id' => 'card123', 'customer_id' => 'cust123'], + ]); + + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('getCustomer->getId')->andReturn('cust123'); + $response->shouldReceive('getCustomer->getReferenceId')->andReturn('ref123'); + $response->shouldReceive('getCard->getId')->andReturn('card123'); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('getHostObject')->andReturn($this->payment); + $square->shouldReceive('createOrFetchCustomer')->andReturn($response); + $square->shouldReceive('createOrFetchCard')->andReturn($response); + $square->shouldReceive('updatePaymentProfileData')->with(Mockery::any(), [ + 'customer_id' => 'cust123', + 'card_id' => 'card123', + ], Mockery::any())->once(); + + $result = $square->updatePaymentProfile($customer, $data); + expect($result->getKey())->toBe($profile->getKey()); +}); + +it('throws exception if createOrFetchCustomer fails for square', function() { + $data = ['square_card_nonce' => 'nonce']; + $this->order->customer = $customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer->customer_id = 1; + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('getHostObject')->andReturn($this->payment); + $square->shouldReceive('createOrFetchCustomer')->andThrow(new ApplicationException('Customer creation failed')); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Customer creation failed'); + + $square->updatePaymentProfile($customer, $data); +}); + +it('throws exception if createOrFetchCard fails for square', function() { + $data = ['square_card_nonce' => 'nonce']; + $this->order->customer = $customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer->customer_id = 1; + + $response = Mockery::mock(ApiResponse::class); + $response->shouldReceive('getCustomer->getId')->andReturn('cust123'); + $response->shouldReceive('getCustomer->getReferenceId')->andReturn('ref123'); + $square = Mockery::mock(Square::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $square->shouldReceive('getHostObject')->andReturn($this->payment); + $square->shouldReceive('createOrFetchCustomer')->andReturn($response); + + $square->shouldReceive('createOrFetchCard')->andThrow(new ApplicationException('Card creation failed')); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Card creation failed'); + + $square->updatePaymentProfile($customer, $data); +}); diff --git a/tests/Payments/StripeTest.php b/tests/Payments/StripeTest.php new file mode 100644 index 0000000..0265b1e --- /dev/null +++ b/tests/Payments/StripeTest.php @@ -0,0 +1,462 @@ +payment = Payment::factory()->create([ + 'class_name' => PaypalExpress::class, + ]); + $this->profile = Mockery::mock(PaymentProfile::class)->makePartial(); + $this->order = Mockery::mock(Order::class)->makePartial(); + $this->order->payment_method = $this->payment; + $this->stripe = new Stripe($this->payment); +}); + +it('returns correct payment form view for stripe', function() { + expect(Stripe::$paymentFormView)->toBe('igniter.payregister::_partials.stripe.payment_form'); +}); + +it('returns correct fields config for stripe', function() { + expect($this->stripe->defineFieldsConfig())->toBe('igniter.payregister::/models/stripe'); +}); + +it('registers correct entry points for stripe', function() { + $entryPoints = $this->stripe->registerEntryPoints(); + + expect($entryPoints)->toBe([ + 'stripe_webhook' => 'processWebhookUrl', + ]); +}); + +it('returns hidden fields for stripe', function() { + $hiddenFields = $this->stripe->getHiddenFields(); + + expect($hiddenFields)->toHaveKeys(['stripe_payment_method', 'stripe_idempotency_key']); +}); + +it('returns correct stripe model value', function($attribute, $methodName, $mode, $value, $returnValue) { + $this->payment->transaction_mode = $mode; + $this->payment->$attribute = $value; + + expect($this->stripe->$methodName())->toBe($returnValue); +})->with([ + ['transaction_mode', 'isTestMode', 'live', 'live', false], + ['transaction_mode', 'isTestMode', 'test', 'test', true], + ['live_publishable_key', 'getPublishableKey', 'live', 'client123', 'client123'], + ['test_publishable_key', 'getPublishableKey', 'test', 'client123', 'client123'], + ['test_secret_key', 'getSecretKey', 'test', 'test_secret_key', 'test_secret_key'], + ['live_secret_key', 'getSecretKey', 'live', 'live_secret_key', 'live_secret_key'], + ['test_webhook_secret', 'getWebhookSecret', 'test', 'test_webhook_secret', 'test_webhook_secret'], + ['live_webhook_secret', 'getWebhookSecret', 'live', 'live_webhook_secret', 'live_webhook_secret'], + ['transaction_type', 'shouldAuthorizePayment', 'test', 'auth_only', true], + ['transaction_type', 'shouldAuthorizePayment', 'live', 'sale', false], +]); + +it('adds correct js files for stripe payment form', function() { + $controller = Mockery::mock(MainController::class); + $controller->shouldReceive('addJs')->with('https://js.stripe.com/v3/', 'stripe-js')->once(); + $controller->shouldReceive('addJs')->with('igniter.payregister::/js/process.stripe.js', 'process-stripe-js')->once(); + + $this->stripe->beforeRenderPaymentForm($this->stripe, $controller); +}); + +it('returns true for completesPaymentOnClient for stripe', function() { + expect($this->stripe->completesPaymentOnClient())->toBeTrue(); +}); + +it('returns stripe js options with locale', function() { + $order = Mockery::mock(Order::class); + $this->payment->locale_code = 'en'; + + Event::listen('payregister.stripe.extendJsOptions', function($stripePayment, $options, $order) { + return [ + 'test_key' => 'test_value', + ]; + }); + + expect($this->stripe->getStripeJsOptions($order))->toBe([ + 'locale' => 'en', + 'test_key' => 'test_value', + ]); +}); + +it('returns stripe options with extended options', function() { + Event::listen('payregister.stripe.extendOptions', function($stripePayment, $options) { + return [ + 'test_key' => 'test_value', + ]; + }); + + expect($this->stripe->getStripeOptions())->toBe([ + 'test_key' => 'test_value', + ]); +}); + +it('creates stripe payment intent successfully', function() { + $this->payment->transaction_mode = 'test'; + $this->payment->test_secret_key = 'test_secret_key'; + $this->order->order_total = 100; + + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false)->once(); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('validateApplicableFee')->with($this->order, $this->payment)->once(); + $stripe->shouldReceive('updatePaymentIntentSession')->with($this->order)->andReturn(null)->once(); + $stripe->shouldReceive('getPaymentFormFields')->with($this->order)->andReturn(['amount' => 1000])->once(); + $stripe->shouldReceive('getStripeOptions')->andReturn([])->once(); + $gateway->shouldReceive('request')->andReturn(StripeObject::constructFrom(['id' => 'pi_123', 'client_secret' => 'secret']))->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway)->once(); + + expect($stripe->createOrFetchIntent($this->order))->toBe('secret'); +}); + +it('fetches & updates stripe payment intent successfully', function() { + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway = Mockery::mock(StripeClient::class); + $gateway->paymentIntents = $paymentIntents; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false)->once(); + $paymentIntents->shouldReceive('retrieve') + ->with('pi_123', Mockery::any(), Mockery::any()) + ->andReturn(StripeObject::constructFrom(['id' => 'pi_123', 'status' => 'not_succeeded']))->once(); + $paymentIntents->shouldReceive('update') + ->with('pi_123', Mockery::on(function($data) { + return empty(array_only((array)$data, ['capture_method', 'setup_future_usage', 'customer'])); + }), Mockery::any()) + ->andReturn(StripeObject::constructFrom(['id' => 'pi_123', 'status' => 'not_succeeded', 'client_secret' => 'secret']))->once(); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('validateApplicableFee')->with($this->order, $this->payment)->once(); + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturn('pi_123')->once(); + $stripe->shouldReceive('getPaymentFormFields')->with($this->order, Mockery::any(), true)->andReturn(['amount' => 1000])->once(); + $stripe->shouldReceive('getStripeOptions')->andReturn([])->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway)->once(); + + expect($stripe->createOrFetchIntent($this->order))->toBe('secret'); +}); + +it('returns null if payment is already processed for stripe', function() { + $order = Mockery::mock(Order::class); + $order->shouldReceive('isPaymentProcessed')->andReturn(true); + + $result = $this->stripe->createOrFetchIntent($order); + expect($result)->toBeNull(); +}); + +it('logs error and returns null if exception occurs in createOrFetchIntent', function() { + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false); + $this->order->shouldReceive('logPaymentAttempt')->with('Creating checkout session failed: Error', 0, [], []); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('validateApplicableFee')->andThrow(new Exception('Error')); + + expect($stripe->createOrFetchIntent($this->order))->toBeNull() + ->and(flash()->messages()->first())->message->toBe('Error'); +}); + +it('processes stripe payment form successfully', function() { + $data = []; + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway = Mockery::mock(StripeClient::class); + $gateway->paymentIntents = $paymentIntents; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment successful', 1, $data, Mockery::any(), true)->once(); + $this->order->shouldReceive('updateOrderStatus')->with($this->payment->order_status, ['notify' => false])->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + $paymentIntents->shouldReceive('retrieve')->andReturn(StripeObject::constructFrom([ + 'status' => 'succeeded', + 'payment_method' => (object)[ + 'id' => 'pm_123', + 'card' => (object)[], + ], + ])); + $stripe->shouldReceive('validateApplicableFee')->with($this->order, $this->payment); + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturn('pi_123')->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $stripe->shouldReceive('forgetSession')->with('ti_payregister_stripe_intent')->once(); + + expect($stripe->processPaymentForm($data, $this->payment, $this->order))->toBeNull(); +}); + +it('throws exception if stripe payment intent id is missing in session', function() { + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + + $this->order->shouldReceive('logPaymentAttempt')->with('Payment error: Missing payment intent identifier in session.', 0, [], [])->once(); + $stripe->shouldReceive('validateApplicableFee')->with($this->order, $this->payment); + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturnNull()->once(); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later.'); + + $stripe->processPaymentForm([], $this->payment, $this->order); +}); + +it('logs error and throws exception if retrieving stripe payment intent fails', function() { + $data = []; + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway = Mockery::mock(StripeClient::class); + $gateway->paymentIntents = $paymentIntents; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + + $this->order->shouldReceive('isPaymentProcessed')->andReturn(false); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment error: Error', 0, $data, Mockery::any())->once(); + $paymentIntents->shouldReceive('retrieve')->andThrow(new Exception('Error')); + $stripe->shouldReceive('validateApplicableFee')->with($this->order, $this->payment); + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturn('pi_123')->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later.'); + + $stripe->processPaymentForm($data, $this->payment, $this->order); +}); + +it('captures authorized stripe payment successfully', function() { + $data = []; + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->response = ['id' => 'pi_123']; + $gateway = Mockery::mock(StripeClient::class); + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn($paymentLog); + $this->order->shouldReceive('payment')->andReturn($this->payment->code); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $paymentIntents->shouldReceive('capture')->andReturn(StripeObject::constructFrom(['status' => 'succeeded'])); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment successful', 1, $data, Mockery::any())->once(); + + $result = $stripe->captureAuthorizedPayment($this->order, $data); + expect($result)->status->toBe('succeeded'); +}); + +it('throws exception if no successful authorized stripe payment to capture', function() { + $data = []; + + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn(null); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No successful authorized payment to capture'); + + $this->stripe->captureAuthorizedPayment($this->order, $data); +}); + +it('throws exception if missing stripe payment intent id in successful authorized payment response', function() { + $data = []; + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn($paymentLog); + $paymentLog->response = []; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Missing payment intent ID in successful authorized payment response'); + + $this->stripe->captureAuthorizedPayment($this->order, $data); +}); + +it('logs error if exception occurs in captureAuthorizedPayment for stripe', function() { + $data = []; + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->response = ['id' => 'pi_123']; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn($paymentLog); + $this->order->shouldReceive('payment')->andReturn($this->payment->code); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $paymentIntents->shouldReceive('capture')->andThrow(new Exception('Error')); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment capture failed: Error', 0, $data)->once(); + + expect($stripe->captureAuthorizedPayment($this->order, $data))->toBeNull(); +}); + +it('cancels authorized stripe payment successfully', function() { + $data = []; + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->response = ['id' => 'pi_123']; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn($paymentLog); + $this->order->shouldReceive('payment')->andReturn($this->payment->code); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $paymentIntents->shouldReceive('cancel')->andReturn((object)['status' => 'canceled']); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment canceled successfully', 1, $data, Mockery::any())->once(); + + expect($stripe->cancelAuthorizedPayment($this->order, $data))->status->toBe('canceled'); +}); + +it('throws exception if no successful authorized stripe payment to cancel', function() { + $data = []; + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn(null); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('No successful authorized payment to cancel'); + + $this->stripe->cancelAuthorizedPayment($this->order, $data); +}); + +it('throws exception if missing stripe payment intent id in successful authorized payment response for cancel', function() { + $data = []; + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn($paymentLog); + $paymentLog->response = []; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Missing payment intent ID in successful authorized payment response'); + + $this->stripe->cancelAuthorizedPayment($this->order, $data); +}); + +it('logs error if exception occurs in cancelAuthorizedPayment for stripe', function() { + $data = []; + $paymentLog = Mockery::mock(PaymentLog::class)->makePartial(); + $paymentLog->response = ['id' => 'pi_123']; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $this->order->shouldReceive('payment_logs->firstWhere')->with('is_success', true)->andReturn($paymentLog); + $this->order->shouldReceive('payment')->andReturn($this->payment->code); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $gateway->paymentIntents->shouldReceive('cancel')->andThrow(new Exception('Error')); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment canceled failed: Error', 0, $data)->once(); + + expect($stripe->cancelAuthorizedPayment($this->order, $data))->toBeNull(); +}); + +it('updates stripe payment intent session successfully', function() { + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturn('pi_123')->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $paymentIntents->shouldReceive('retrieve')->andReturn(StripeObject::constructFrom(['id' => 'pi_123', 'status' => 'requires_payment_method']))->once(); + $stripe->shouldReceive('getPaymentFormFields')->with($this->order, [], true)->andReturn(['amount' => 1000]); + $stripe->shouldReceive('getStripeOptions')->andReturn([]); + $paymentIntents->shouldReceive('update')->andReturn(StripeObject::constructFrom(['id' => 'pi_123']))->once(); + + expect($stripe->updatePaymentIntentSession($this->order))->id->toBe('pi_123'); +}); + +it('returns stripe payment intent if status is requires_capture or succeeded', function() { + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturn('pi_123')->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $gateway->paymentIntents->shouldReceive('retrieve')->andReturn((object)['status' => 'requires_capture']); + + expect($stripe->updatePaymentIntentSession($this->order))->status->toBe('requires_capture'); +}); + +it('logs error and returns false if exception occurs in updatePaymentIntentSession', function() { + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $stripe->shouldReceive('getSession')->with('ti_payregister_stripe_intent')->andReturn('pi_123')->once(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $gateway->paymentIntents->shouldReceive('retrieve')->andThrow(new Exception('Error')); + $this->order->shouldReceive('logPaymentAttempt')->with('Updating checkout session failed: Error', 1, [], Mockery::any())->once(); + + expect($stripe->updatePaymentIntentSession($this->order))->toBeFalse(); +}); + +it('throws exception if stripe payment profile not found', function() { + $this->order->customer = null; + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Payment profile not found or customer not logged in'); + + $this->stripe->payFromPaymentProfile($this->order, []); +}); + +it('throws exception if stripe payment profile has no data', function() { + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer->customer_id = 1; + PaymentProfile::factory()->create([ + 'customer_id' => 1, + ]); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Payment profile not found or customer not logged in'); + + $this->stripe->payFromPaymentProfile($this->order, []); +}); + +it('creates payment successfully from stripe payment profile', function() { + $data = []; + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('paymentProfileExists')->andReturnTrue(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $stripe->shouldReceive('getStripeOptions')->andReturn([])->once(); + $stripe->shouldReceive('getPaymentFormFields')->with($this->order)->andReturn(['amount' => 1000])->once(); + $paymentIntents->shouldReceive('create')->andReturn(StripeObject::constructFrom(['status' => 'succeeded']))->once(); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment successful', 1, Mockery::any(), Mockery::any(), true)->once(); + $this->order->shouldReceive('updateOrderStatus')->with($this->payment->order_status, ['notify' => false])->once(); + $this->order->shouldReceive('markAsPaymentProcessed')->once(); + + $stripe->payFromPaymentProfile($this->order, $data); +}); + +it('logs payment attempt and throws exception if stripe payment creation fails', function() { + $stripe = Mockery::mock(Stripe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $gateway = Mockery::mock(StripeClient::class); + $paymentIntents = Mockery::mock(PaymentIntentService::class); + $gateway->paymentIntents = $paymentIntents; + + $this->order->customer = Mockery::mock(Customer::class)->makePartial(); + $this->order->customer_name = 'John Doe'; + $stripe->shouldReceive('getHostObject')->andReturn($this->payment); + $stripe->shouldReceive('paymentProfileExists')->andReturnTrue(); + $stripe->shouldReceive('createGateway')->andReturn($gateway); + $stripe->shouldReceive('getPaymentFormFields')->with($this->order)->andReturn(['amount' => 1000])->once(); + $paymentIntents->shouldReceive('create')->andThrow(new Exception('Payment error')); + $this->order->shouldReceive('logPaymentAttempt')->with('Payment error: Payment error', 0, Mockery::any(), Mockery::any())->once(); + + $this->expectException(ApplicationException::class); + $this->expectExceptionMessage('Sorry, there was an error processing your payment. Please try again later'); + + $stripe->payFromPaymentProfile($this->order, []); +}); diff --git a/tests/Pest.php b/tests/Pest.php index b3d9bbc..f8986fd 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1 +1,10 @@ in(__DIR__); + +function actingAsSuperUser() +{ + return test()->actingAs(User::factory()->superUser()->create(), 'igniter-admin'); +} diff --git a/tests/Subscribers/FormFieldsSubscriberTest.php b/tests/Subscribers/FormFieldsSubscriberTest.php new file mode 100644 index 0000000..9eac49e --- /dev/null +++ b/tests/Subscribers/FormFieldsSubscriberTest.php @@ -0,0 +1,45 @@ +subscriber = new FormFieldsSubscriber(); + $this->form = new class extends Form + { + public function __construct() {} + }; + $this->order = Mockery::mock(Order::class)->makePartial(); +}); + +it('subscribes to admin.form.extendFieldsBefore event', function() { + $events = $this->subscriber->subscribe(Mockery::mock(Dispatcher::class)); + + expect($events)->toHaveKey('admin.form.extendFieldsBefore') + ->and($events['admin.form.extendFieldsBefore'])->toBe('handle'); +}); + +it('adds payment logs field to form if model is Order', function() { + $this->form->model = $this->order; + $this->form->tabs = ['fields' => []]; + + $this->subscriber->handle($this->form); + + expect($this->form->tabs['fields'])->toHaveKey('payment_logs') + ->and($this->form->tabs['fields']['payment_logs']['tab'])->toBe('lang:igniter.payregister::default.text_payment_logs') + ->and($this->form->tabs['fields']['payment_logs']['type'])->toBe('paymentattempts'); +}); + +it('does not add payment logs field to form if model is not Order', function() { + $this->form->model = Mockery::mock('NonOrderModel'); + $this->form->tabs = ['fields' => []]; + + $this->subscriber->handle($this->form); + + expect($this->form->tabs['fields'])->not->toHaveKey('payment_logs'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index 84e81cc..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,14 +0,0 @@ - [ + 'test_field' => [ + 'label' => 'Test Field', + 'type' => 'text', + ], + ], + 'rules' => [ + ['test_field', 'lang:igniter.payregister::default.stripe.label_test_field', 'required|string'], + ], + 'validationAttributes' => [ + 'test_field' => 'lang:igniter.payregister::default.stripe.label_test_field', + ], + 'validationMessages' => [ + 'test_field.required' => 'lang:igniter.payregister::default.stripe.label_test_field', + 'test_field.string' => 'lang:igniter.payregister::default.stripe.label_test_field', + ], +];