Skip to content

Commit

Permalink
Merge pull request #48 from happypixels/feature/sca-support
Browse files Browse the repository at this point in the history
Implements the Stripe Payment Intents API, supporting SCA and 3D Secure 2 auth
  • Loading branch information
mattias-persson authored Sep 12, 2019
2 parents 2c9bc22 + 36849d6 commit 8cc3992
Show file tree
Hide file tree
Showing 17 changed files with 242 additions and 33 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/Cart/Cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ public function convertToOrder($gateway, $data = [])
}
}

$this->clear();
event('shopr.orders.created', $order);

return $order;
}
Expand Down
10 changes: 7 additions & 3 deletions src/Controllers/CheckoutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions src/Controllers/Web/PaymentConfirmationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace Happypixels\Shopr\Controllers\Web;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Happypixels\Shopr\Models\Order;
use Happypixels\Shopr\Exceptions\PaymentFailedException;
use Happypixels\Shopr\PaymentProviders\PaymentProviderManager;

class PaymentConfirmationController extends Controller
{
/**
* Attempts to confirm a payment. Returns an error view if unsuccessful and reidrects to
* the order confirmation view otherwise.
*
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$request->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]);
}
}
10 changes: 10 additions & 0 deletions src/Exceptions/PaymentFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 14 additions & 3 deletions src/Middleware/RequireOrderToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
8 changes: 5 additions & 3 deletions src/Observers/OrderObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}
Expand All @@ -34,7 +37,6 @@ public function created(Order $order)
*/
public function updated(Order $order)
{
//
}

/**
Expand Down
38 changes: 38 additions & 0 deletions src/PaymentProviders/PaymentProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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());
}
Expand Down
33 changes: 32 additions & 1 deletion src/PaymentProviders/Stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Happypixels\Shopr\PaymentProviders;

use Omnipay\Omnipay;

class Stripe extends PaymentProvider
{
/**
Expand All @@ -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;
}
}
2 changes: 2 additions & 0 deletions src/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@
->name('shopr.order-confirmation')
->middleware(Happypixels\Shopr\Middleware\RequireOrderToken::class);
}

Route::get('payments/confirm', 'PaymentConfirmationController')->name('shopr.payments.confirm');
});
11 changes: 10 additions & 1 deletion src/ShoprServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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);
});
}

Expand Down
38 changes: 38 additions & 0 deletions src/Views/payments/error.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />

<title>{{ __('Payment Confirmation') }} - {{ config('app.name', 'Laravel') }}</title>

<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="font-sans text-gray-600 bg-gray-200 leading-normal p-4 h-full">
<div id="app" class="h-full md:flex md:justify-center md:items-center">
<div class="w-full max-w-lg">
<div class="bg-white rounded-lg shadow-xl p-4 sm:py-6 sm:px-10 mb-5">

<h1 class="text-xl mt-2 mb-4 text-gray-700 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="flex-shrink-0 w-6 h-6 mr-2">
<path class="fill-current text-red-300" d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20z"/>
<path class="fill-current text-red-500" d="M12 18a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm1-5.9c-.13 1.2-1.88 1.2-2 0l-.5-5a1 1 0 0 1 1-1.1h1a1 1 0 0 1 1 1.1l-.5 5z"/>
</svg>

{{ trans('shopr::checkout.payment_failed') }}
</h1>

<p class="mb-6">{{ $message }}</p>

<a href="{{ url('/') }}" class="inline-block w-full px-4 py-3 bg-gray-200 hover:bg-gray-300 text-center text-gray-700 rounded-lg">
{{ __('Return to home page') }}
</a>
</div>

<p class="text-center text-gray-500 text-sm">
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
</p>
</div>
</div>
</body>
</html>
5 changes: 3 additions & 2 deletions tests/Feature/Mails/OrderCreatedAdminsMailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ private function createTestOrder()
$model = TestShoppable::first();
$cart->addItem(get_class($model), 1, 1);

$userData = [
$data = [
'payment_status' => 'paid',
'email' => 'test@example.com',
'first_name' => 'Testy',
'last_name' => 'McTestface',
Expand All @@ -124,7 +125,7 @@ private function createTestOrder()
'country' => 'US',
];

$order = $cart->convertToOrder('stripe', $userData);
$order = $cart->convertToOrder('stripe', $data);

event('shopr.orders.created', $order);

Expand Down
Loading

0 comments on commit 8cc3992

Please sign in to comment.