Skip to content

Commit

Permalink
Implement Payment Intents for charges and subscriptions
Browse files Browse the repository at this point in the history
These changes bring support for the new payment intents api to charges and subscriptions. As there are quite some breaking changes here let's go over the most prominent ones below:

Any payment action will now throw an exception when a payment either fails or when the payment requires a secondary action in order to be completed. This goes for single charges, invoicing customers directly, subscribing to a new plan or swapping plans. Developers can catch these exceptions and decide for themselves how to handle these by either letting Stripe handle everything for them (to be set up in the Stripe dashboard) or use the custom built-in solution which will be added in the next commit.

A new status column is introduced for subscriptions as well. Whenever an attempt is made to subscribe to a plan but a secondary payment action is required, the subscription will be put into a state of "incomplete" while payment confirmation is awaited. As soon as payment has been properly processed, a webhook will update the subscription's status to active.

After these changes, webhooks will be a fundamental part of how Cashier works and they're now required in order to properly handle any payment confirmations and off-session updates to subscriptions & customers.

The charge method now only accepts a payment method instead of a token. Developers will need to update their JS integration to retrieve a payment method id instead of a source token. These changes were done because this is now the recommended way by Stripe to work with payment methods. More info about that can be found here: https://stripe.com/docs/payments/payment-methods#transitioning
In an upcoming update all card methods as well as the create method on the subscription builder will be updated as well.

Closes #636
  • Loading branch information
driesvints committed Jul 2, 2019
1 parent c30353d commit 0a6214a
Show file tree
Hide file tree
Showing 13 changed files with 500 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public function up()
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->string('name');
$table->string('status');
$table->string('stripe_id')->collation('utf8mb4_bin');
$table->string('stripe_plan');
$table->integer('quantity');
Expand Down
39 changes: 28 additions & 11 deletions src/Billable.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
use Stripe\Card as StripeCard;
use Stripe\Token as StripeToken;
use Illuminate\Support\Collection;
use Stripe\Charge as StripeCharge;
use Stripe\Refund as StripeRefund;
use Stripe\Invoice as StripeInvoice;
use Stripe\Customer as StripeCustomer;
use Stripe\BankAccount as StripeBankAccount;
use Stripe\InvoiceItem as StripeInvoiceItem;
use Stripe\Error\Card as StripeCardException;
use Stripe\PaymentIntent as StripePaymentIntent;
use Laravel\Cashier\Exceptions\InvalidStripeCustomer;
use Stripe\Error\InvalidRequest as StripeErrorInvalidRequest;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
Expand All @@ -25,12 +25,14 @@ trait Billable
*
* @param int $amount
* @param array $options
* @return \Stripe\Charge
* @return \Laravel\Cashier\Payment
* @throws \InvalidArgumentException
*/
public function charge($amount, array $options = [])
{
$options = array_merge([
'confirmation_method' => 'automatic',
'confirm' => true,
'currency' => $this->preferredCurrency(),
], $options);

Expand All @@ -40,26 +42,32 @@ public function charge($amount, array $options = [])
$options['customer'] = $this->stripe_id;
}

if (! array_key_exists('source', $options) && ! array_key_exists('customer', $options)) {
throw new InvalidArgumentException('No payment source provided.');
if (! array_key_exists('payment_method', $options) && ! array_key_exists('customer', $options)) {
throw new InvalidArgumentException('No payment method provided.');
}

return StripeCharge::create($options, Cashier::stripeOptions());
$payment = new Payment(
StripePaymentIntent::create($options, Cashier::stripeOptions())
);

$payment->validate();

return $payment;
}

/**
* Refund a customer for a charge.
*
* @param string $charge
* @param string $paymentIntent
* @param array $options
* @return \Stripe\Refund
* @throws \InvalidArgumentException
*/
public function refund($charge, array $options = [])
public function refund($paymentIntent, array $options = [])
{
$options['charge'] = $charge;
$intent = StripePaymentIntent::retrieve($paymentIntent, Cashier::stripeOptions());

return StripeRefund::create($options, Cashier::stripeOptions());
return $intent->charges->data[0]->refund($options);
}

/**
Expand Down Expand Up @@ -217,9 +225,18 @@ public function invoice(array $options = [])
$parameters = array_merge($options, ['customer' => $this->stripe_id]);

try {
return StripeInvoice::create($parameters, Cashier::stripeOptions())->pay();
/** @var \Stripe\Invoice $invoice */
$invoice = StripeInvoice::create($parameters, Cashier::stripeOptions());

return $invoice->pay();
} catch (StripeErrorInvalidRequest $e) {
return false;
} catch (StripeCardException $exception) {
$payment = new Payment(
StripePaymentIntent::retrieve($invoice->refresh()->payment_intent, Cashier::stripeOptions())
);

$payment->validate();
}
}

Expand Down
33 changes: 33 additions & 0 deletions src/Exceptions/IncompletePayment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Laravel\Cashier\Exceptions;

use Exception;
use Throwable;
use Laravel\Cashier\Payment;

class IncompletePayment extends Exception
{
/**
* The Cashier Payment object.
*
* @var \Laravel\Cashier\Payment
*/
public $payment;

/**
* Create a new IncompletePayment instance.
*
* @param \Laravel\Cashier\Payment $payment
* @param string $message
* @param int $code
* @param \Throwable|null $previous
* @return void
*/
public function __construct(Payment $payment, $message = '', $code = 0, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);

$this->payment = $payment;
}
}
22 changes: 22 additions & 0 deletions src/Exceptions/PaymentActionRequired.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Laravel\Cashier\Exceptions;

use Laravel\Cashier\Payment;

class PaymentActionRequired extends IncompletePayment
{
/**
* Create a new PaymentActionRequired instance.
*
* @param \Laravel\Cashier\Payment $payment
* @return self
*/
public static function incomplete(Payment $payment)
{
return new self(
$payment,
'The payment attempt failed because it needs an extra action before it can be completed.'
);
}
}
22 changes: 22 additions & 0 deletions src/Exceptions/PaymentFailure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Laravel\Cashier\Exceptions;

use Laravel\Cashier\Payment;

class PaymentFailure extends IncompletePayment
{
/**
* Create a new PaymentFailure instance.
*
* @param \Laravel\Cashier\Payment $payment
* @return self
*/
public static function cardError(Payment $payment)
{
return new self(
$payment,
'The payment attempt failed because there was a card error.'
);
}
}
14 changes: 0 additions & 14 deletions src/Exceptions/SubscriptionCreationFailed.php

This file was deleted.

9 changes: 9 additions & 0 deletions src/Http/Controllers/WebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ protected function handleCustomerSubscriptionUpdated(array $payload)
}
}

// Status...
if (isset($data['status'])) {
if (in_array($data['status'], ['incomplete', 'incomplete_expired'])) {
$subscription->status = 'incomplete';
} else {
$subscription->status = 'active';
}
}

$subscription->save();
});
}
Expand Down
146 changes: 146 additions & 0 deletions src/Payment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

namespace Laravel\Cashier;

use Laravel\Cashier\Exceptions\PaymentFailure;
use Stripe\PaymentIntent as StripePaymentIntent;
use Laravel\Cashier\Exceptions\PaymentActionRequired;

class Payment
{
/**
* The Stripe PaymentIntent instance.
*
* @var \Stripe\PaymentIntent
*/
protected $paymentIntent;

/**
* Create a new Payment instance.
*
* @param \Stripe\PaymentIntent $paymentIntent
* @return void
*/
public function __construct(StripePaymentIntent $paymentIntent)
{
$this->paymentIntent = $paymentIntent;
}

/**
* The Stripe PaymentIntent ID.
*
* @return string
*/
public function id()
{
return $this->paymentIntent->id;
}

/**
* Get the total amount that will be paid.
*
* @return string
*/
public function amount()
{
return Cashier::formatAmount($this->rawAmount(), $this->paymentIntent->currency);
}

/**
* Get the raw total amount that will be paid.
*
* @return int
*/
public function rawAmount()
{
return $this->paymentIntent->amount;
}

/**
* The Stripe PaymentIntent client secret.
*
* @return string
*/
public function clientSecret()
{
return $this->paymentIntent->client_secret;
}

/**
* Determine if the payment needs a valid payment method.
*
* @return bool
*/
public function requiresPaymentMethod()
{
return $this->paymentIntent->status === 'requires_payment_method';
}

/**
* Determine if the payment needs an extra action like 3D Secure.
*
* @return bool
*/
public function requiresAction()
{
return $this->paymentIntent->status === 'requires_action';
}

/**
* Determine if the payment was cancelled.
*
* @return bool
*/
public function isCancelled()
{
return $this->paymentIntent->status === 'cancelled';
}

/**
* Determine if the payment was successful.
*
* @return bool
*/
public function isSucceeded()
{
return $this->paymentIntent->status === 'succeeded';
}

/**
* Validate if the payment intent was successful and throw an exception if not.
*
* @return void
*
* @throws \Laravel\Cashier\Exceptions\PaymentActionRequired
* @throws \Laravel\Cashier\Exceptions\PaymentFailure
*/
public function validate()
{
if ($this->requiresPaymentMethod()) {
throw PaymentFailure::cardError($this);
} elseif ($this->requiresAction()) {
throw PaymentActionRequired::incomplete($this);
}
}

/**
* The Stripe PaymentIntent instance.
*
* @return \Stripe\PaymentIntent
*/
public function asStripePaymentIntent()
{
return $this->paymentIntent;
}

/**
* Dynamically get values from the Stripe PaymentIntent.
*
* @param string $key
* @return mixed
*/
public function __get($key)
{
return $this->paymentIntent->{$key};
}
}
Loading

0 comments on commit 0a6214a

Please sign in to comment.