diff --git a/README.md b/README.md index 208189c..b165fe6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Full documentation here: https://laravel-shopr.happypixels.se * Shopping cart * Discount coupons * Any model can be shoppable -* Checkout process with payment solutions out of the box +* SCA-ready checkout process with payment solutions out of the box * Cart to Order conversion * Automated order emails to the customer and administrators * A simple REST API for managing the cart and checkout @@ -46,12 +46,17 @@ Publish and review the configuration: php artisan vendor:publish --provider="Happypixels\Shopr\ShoprServiceProvider" --tag="config" ``` +Optionally you may publish the views to make them modifiable: +```bash +php artisan vendor:publish --provider="Happypixels\Shopr\ShoprServiceProvider" --tag="views" +``` + Optionally you may publish the translation files to make them modifiable: ```bash php artisan vendor:publish --provider="Happypixels\Shopr\ShoprServiceProvider" --tag="translations" ``` -After this, refer to the [extensive documentation](https://laravel-shopr.happypixels.se) to get started. +After this, refer to the [documentation](https://laravel-shopr.happypixels.se) to get started. ## Contributing Found a bug or have a feature request? [Open an issue on Github](https://github.com/happypixels/laravel-shopr/issues). diff --git a/composer.json b/composer.json index bdc88c2..7056de9 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "minimum-stability": "dev", "require": { "league/omnipay": "^3", - "omnipay/stripe": "^3.0", + "omnipay/stripe": "3.1.x-dev#37df2a791e8feab45543125f4c5f22d5d305096d", "moneyphp/money": "^3.1", "illuminate/support": "~5.5.0|~5.6.0|~5.7.0|~5.8.0|^6.0" }, diff --git a/src/Cart/Cart.php b/src/Cart/Cart.php index 2869b6d..87e86d4 100644 --- a/src/Cart/Cart.php +++ b/src/Cart/Cart.php @@ -274,7 +274,7 @@ public function convertToOrder($gateway, $data = []) } } - $this->clear(); + event('shopr.orders.created', $order); return $order; } diff --git a/src/Controllers/CheckoutController.php b/src/Controllers/CheckoutController.php index 18ec9c9..f9ddb50 100644 --- a/src/Controllers/CheckoutController.php +++ b/src/Controllers/CheckoutController.php @@ -34,19 +34,23 @@ public function charge(Request $request) return response()->json(['message' => trans('shopr::cart.cart_is_empty')], 400); } + $response = PaymentProviderManager::make($request)->payForCart(); + // Make the payment and merge the response with the request data, if successful. $data = array_merge($request->only([ 'email', 'phone', 'first_name', 'last_name', 'address', 'zipcode', 'city', 'country', - ]), PaymentProviderManager::make($request)->payForCart()); + ]), $response); $order = $this->cart->convertToOrder($request->gateway, $data); - event('shopr.orders.created', $order); + if (! $response['success']) { + return response()->json($response); + } $response = ['token' => $order->token]; if (config('shopr.templates.order-confirmation')) { - $response['redirect'] = route('shopr.order-confirmation').'?token='.$order->token; + $response['redirect'] = route('shopr.order-confirmation', ['token' => $order->token]); } return response()->json($response, 201); diff --git a/src/Controllers/Web/PaymentConfirmationController.php b/src/Controllers/Web/PaymentConfirmationController.php new file mode 100644 index 0000000..008ebe2 --- /dev/null +++ b/src/Controllers/Web/PaymentConfirmationController.php @@ -0,0 +1,51 @@ +validate(['gateway' => 'required']); + + try { + $response = PaymentProviderManager::make($request)->confirmPayment(); + } catch (PaymentFailedException $e) { + optional( + Order::where('transaction_reference', $request->payment_intent)->first() + )->update(['payment_status' => 'failed']); + + return view('shopr::payments.error')->with('message', $e->getMessage()); + } + + $order = Order::where('transaction_reference', $request->payment_intent)->firstOrFail(); + + $previousStatus = $order->payment_status; + + $order->update([ + 'payment_status' => 'paid', + 'transaction_reference' => $response['transaction_reference'], + ]); + + // If the previous status of the order is not 'paid', fire the event to indicate + // the order has now been confirmed. + if ($previousStatus !== 'paid') { + event('shopr.orders.confirmed', $order); + } + + return redirect()->route('shopr.order-confirmation', ['token' => $order->token]); + } +} diff --git a/src/Exceptions/PaymentFailedException.php b/src/Exceptions/PaymentFailedException.php index 0b709e9..7d816d1 100644 --- a/src/Exceptions/PaymentFailedException.php +++ b/src/Exceptions/PaymentFailedException.php @@ -6,8 +6,18 @@ class PaymentFailedException extends Exception { + /** + * The message, typically the reason why the payment has failed. + * + * @var string + */ protected $message; + /** + * Create an instance of the exception. + * + * @param string $message + */ public function __construct($message) { $this->message = $message; diff --git a/src/Middleware/RequireOrderToken.php b/src/Middleware/RequireOrderToken.php index 5d5c786..3d48e12 100644 --- a/src/Middleware/RequireOrderToken.php +++ b/src/Middleware/RequireOrderToken.php @@ -17,12 +17,23 @@ class RequireOrderToken */ public function handle($request, Closure $next, $guard = null) { - $token = $request->query('token'); - - if (! $token || Order::where('token', $token)->where('payment_status', 'paid')->count() === 0) { + if (! $this->hasValidOrderToken($request)) { return redirect('/'); } return $next($request); } + + /** + * Returns true if the request has a token matching a paid order. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function hasValidOrderToken($request) + { + $token = $request->query('token'); + + return $token && Order::where('token', $token)->where('payment_status', 'paid')->count() > 0; + } } diff --git a/src/Observers/OrderObserver.php b/src/Observers/OrderObserver.php index c389cf9..bb5a783 100644 --- a/src/Observers/OrderObserver.php +++ b/src/Observers/OrderObserver.php @@ -2,6 +2,7 @@ namespace Happypixels\Shopr\Observers; +use Happypixels\Shopr\Cart\Cart; use Happypixels\Shopr\Models\Order; use Illuminate\Support\Facades\Mail; use Happypixels\Shopr\Mails\OrderCreatedAdmins; @@ -10,13 +11,15 @@ class OrderObserver { /** - * Handle to the Order "created" event. + * Handle to the Order "confirmed" event. * * @param \Happypixels\Shopr\Models\Order $order * @return void */ - public function created(Order $order) + public function confirmed(Order $order) { + app(Cart::class)->clear(); + if (config('shopr.mail.customer.order_placed.enabled') !== false && $order->email) { Mail::to($order->email)->queue(new OrderCreatedCustomer($order)); } @@ -34,7 +37,6 @@ public function created(Order $order) */ public function updated(Order $order) { - // } /** diff --git a/src/PaymentProviders/PaymentProvider.php b/src/PaymentProviders/PaymentProvider.php index a9e49cb..a0f2a09 100644 --- a/src/PaymentProviders/PaymentProvider.php +++ b/src/PaymentProviders/PaymentProvider.php @@ -29,6 +29,14 @@ public function __construct() */ abstract public function purchase(); + /** + * The data used for confirming a payment, used for example when confirming a payment using SCA. + * The payment reference should be found in the $this->input-array. + * + * @return array + */ + abstract public function getPaymentConfirmationData() : array; + /** * Makes the purchase and returns the results if successful. Throws exception if unsuccessful. * @@ -38,6 +46,36 @@ public function payForCart() { $response = $this->purchase(); + if ($response->isRedirect()) { + return [ + 'success' => false, + 'transaction_reference' => $response->getPaymentIntentReference(), + 'redirect' => $response->getRedirectUrl(), + 'payment_status' => 'pending', + ]; + } + + if (! $response->isSuccessful()) { + throw new PaymentFailedException($response->getMessage()); + } + + return [ + 'success' => true, + 'transaction_reference' => $response->getTransactionReference(), + 'transaction_id' => $response->getTransactionId(), + 'payment_status' => 'paid', + ]; + } + + /** + * Confirms a payment if needed. + * + * @return array + */ + public function confirmPayment() + { + $response = $this->gateway->confirm($this->getPaymentConfirmationData())->send(); + if (! $response->isSuccessful()) { throw new PaymentFailedException($response->getMessage()); } diff --git a/src/PaymentProviders/Stripe.php b/src/PaymentProviders/Stripe.php index 9731a70..70c26e5 100644 --- a/src/PaymentProviders/Stripe.php +++ b/src/PaymentProviders/Stripe.php @@ -2,6 +2,8 @@ namespace Happypixels\Shopr\PaymentProviders; +use Omnipay\Omnipay; + class Stripe extends PaymentProvider { /** @@ -14,7 +16,36 @@ public function purchase() return $this->gateway->purchase([ 'amount' => $this->cart->total(), 'currency' => config('shopr.currency'), - 'token' => $this->input['token'], + 'paymentMethod' => $this->input['payment_method_id'], + 'returnUrl' => route('shopr.payments.confirm', ['gateway' => 'Stripe']), + 'confirm' => true, ])->send(); } + + /** + * The data used for confirming a payment, used for example when confirming a payment using SCA. + * The payment reference should be found in the $this->input-array. + * + * @return array + */ + public function getPaymentConfirmationData() : array + { + return [ + 'paymentIntentReference' => $this->input['payment_intent'], + 'returnUrl' => route('shopr.order-confirmation', ['gateway' => 'Stripe']), + ]; + } + + /** + * Initializes and authorizes the gateway with the credentials. + * + * @return Happypixels\Shopr\PaymentProviders\PaymentProvider + */ + public function initialize() + { + $this->gateway = Omnipay::create('Stripe_PaymentIntents'); + $this->gateway->initialize($this->config); + + return $this; + } } diff --git a/src/Routes/web.php b/src/Routes/web.php index ce11b8d..247881e 100644 --- a/src/Routes/web.php +++ b/src/Routes/web.php @@ -25,4 +25,6 @@ ->name('shopr.order-confirmation') ->middleware(Happypixels\Shopr\Middleware\RequireOrderToken::class); } + + Route::get('payments/confirm', 'PaymentConfirmationController')->name('shopr.payments.confirm'); }); diff --git a/src/ShoprServiceProvider.php b/src/ShoprServiceProvider.php index ca5aa20..ea9acc4 100644 --- a/src/ShoprServiceProvider.php +++ b/src/ShoprServiceProvider.php @@ -31,6 +31,9 @@ public function boot() $this->loadRoutesFrom(__DIR__.'/Routes/web.php'); $this->loadViewsFrom(__DIR__.'/Views', 'shopr'); + $this->publishes([ + __DIR__.'/Views' => $this->app->resourcePath('views/vendor/shopr'), + ], 'views'); $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'shopr'); $this->publishes([ @@ -42,7 +45,13 @@ public function boot() // We manually register the events here rather than automatically registering the observer // because we want to be in control of when the events are fired. Event::listen('shopr.orders.created', function (Order $order) { - (new OrderObserver)->created($order); + if ($order->payment_status === 'paid') { + event('shopr.orders.confirmed', $order); + } + }); + + Event::listen('shopr.orders.confirmed', function (Order $order) { + (new OrderObserver)->confirmed($order); }); } diff --git a/src/Views/payments/error.blade.php b/src/Views/payments/error.blade.php new file mode 100644 index 0000000..e670d0d --- /dev/null +++ b/src/Views/payments/error.blade.php @@ -0,0 +1,38 @@ + + +
+ + + +{{ $message }}
+ + + {{ __('Return to home page') }} + ++ © {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} +
+