laravel / cashier-stripe

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.
https://laravel.com/docs/billing
MIT License
2.38k stars 679 forks source link

Payment Intents #636

Closed driesvints closed 5 years ago

driesvints commented 5 years ago

The new Payment Intents API is needed to make payments SCA compliant. This will require some significant changes in the public API of Cashier as well as making Cashier relient on webhooks to verify payments.

SCA Info: https://stripe.com/docs/strong-customer-authentication Payment Intents: https://stripe.com/docs/payments/payment-intents Billing migration guide: https://stripe.com/docs/billing/migration/strong-customer-authentication

MarGul commented 5 years ago

Instead of being reliant on webhooks we could maybe use the Manual confirmation? The code doesn't look to hard to write. You would have to have some type of API that handles the different statuses and send back responses to the front-end. Here is the docs: https://stripe.com/docs/payments/payment-intents/quickstart#flow-manual-confirmation

driesvints commented 5 years ago

@MarGul nice. They only recently posted that I believe. Gonna look at this in detail when I get to this issue.

garygreen commented 5 years ago

I too am looking into this, not only as a way to be SCA compliant but also to help prevent bank disputes. It's a pretty critical feature.

The flow seems a bit ambiougs to me. If anyone can make sense of it / clarify could you comment? It would also help Cashier implement a flow that works with plans/subscriptions.

The Payment Intent Flow (as I understand it) - using stripe js / php and with a subscription / plan

  1. You create a payment intent on server side when you hit the checkout page (and store this in session for future use) using the details of your plan:
$plan = \Stripe\Plan::retrieve('your-subscription-plan-id', [
    'api_key' => config('services.stripe.secret'),
]);

$paymentIntent = \Stripe\PaymentIntent::create([
    'amount'               => $plan->amount,
    'currency'             => $plan->currency,
    'payment_method_types' => ['card'],
], [
    'api_key' => config('services.stripe.secret'),
]);

You can then pass the payment intent details onto your view / form so that it can be used in js when you eventually make a call to handleCardPayment

  1. In javascript, you collect card details / mount etc in the usual way and instead of making a call to stripe.createToken() instead you call stripe.handleCardPayment():
stripe.handleCardPayment(document.querySelector('input[name="payment_intent_secret"]').value, card).then(function(result) {
    if (result.error) {
        // Payment failed.
    } else {
        // Payment successful.
    }
});
  1. This is where I get a bit confused, but I'm assuming at that point if the payment was successful, you can create a token for the user and susbcribe them to the plan. I would imagine you would want it to skip the first payment as you have already paid for the first month in the payment intent? Maybe this is where trial period comes into play? I'm also not sure where the web hook comes into play here, as I would assume if the callback is successful then you don't need to do any further checks?

  2. At that point on the server you can clear the payment intent - maybe this is where the web hook comes into play? Is it needed though when you can just get the payment intent when you hit the checkout page, check its status to see if its "expired" and instead use a new one:

$paymentIntent = null;
if (session('paymentIntentId')) {
    $paymentIntent = \Stripe\PaymentIntent::retrieve(session('paymentIntentId'), [
        'api_key' => config('services.stripe.secret'),
    ]);
}

$expiredPaymentIntent = $paymentIntent && in_array($paymentIntent->status, ['canceled', 'succeeded']);

if (! $paymentIntent || $expiredPaymentIntent) {
   // Create new payment intent here, as its either not created or old one has expired.
   // ... 
   // ..
   // Remember it in session so we don't create a new payment intent everytime checkout page is reloaded:
   session()->put('paymentIntentId', $paymentIntent->id, now()->addDay());
}

If anyone can shed light on the flow / clarify how they've got it working in their app that would be much appreciated.

driesvints commented 5 years ago

Hey @garygreen, thanks for helping out! I'll look into your reply as soon as I get to the issue.

garygreen commented 5 years ago

Ok so I spoke with Stripe and they have informed me this is the best place to understand the workflow for payment intents with subscriptions: https://stripe.com/docs/billing/subscriptions/payment

(that docmentation only went live on Thursday, so its still a WIP but it's much better place to understand than the other docs because they focus on one-off payments rather than subscriptions)

The key factor is a subscription may now enter a new status called incomplete which means it will have a payment_intent object and a client secret - you then need to use something like Stripe.js to go back to the browser with the payment intent secret and call the Stripe.js with:

var stripe = Stripe('key here');

// This can be found on invoice.payment_intent.client_secret
var paymentIntentSecret = 'pi_91_secret_W9';

stripe.handleCardPayment(
    paymentIntentSecret
).then(function (result) {
    if (result.error) {
        // Display error.message in your UI.
    } else {
        // The payment has succeeded. Display a success message.
    }
})

... that will then prompt for any 3D secure payments / other things needed to complete the payment. A webhook will then fire to note that the payment went thru invoice.payment_succeeded

Hopefully that helps. The docs explain it better but that's how I've understood it.

driesvints commented 5 years ago

@garygreen that helps! We actually need to revert the behavior I added in https://github.com/laravel/cashier/pull/631 because of the incomplete status you mentioned above. It was a good fix for 9.x because it reverted Cashier to the same behavior as before but with Payment Intents we indeed need to accommodate for a window where the customer is making the payment with 3D secure, etc. Webhooks will later update the subscription if payment failed.

garygreen commented 5 years ago

Bit more info - you can also pass a expand parameter during the creation of the subscription which allows you to access the PaymentIntent object without creating multiple API requests. This will be very useful when needing to go back to the browser/client with the payment intent secret to do any nessscary steps to complete the payment.

Example in node, but hopefully get the idea:

let subscription = await stripe.subscriptions.create({
    customer: customer.id,
    items: [
        {
            plan: "plan_DQYe83yUGgx1LE",
        },
    ],
    expand : ["latest_invoice.payment_intent"]
}); 
garygreen commented 5 years ago

Also I've had to override the SubcriptionBuilder payload method in order for Stripe to not fail payment instantly if needing 3D check:

<?php

namespace App\Cashier;

use Laravel\Cashier\SubscriptionBuilder as LaravelSubscriptionBuilder;

class SubscriptionBuilder extends LaravelSubscriptionBuilder
{
    /**
     * Build the payload for subscription creation.
     *
     * @return array
     */
    protected function buildPayload()
    {
        return array_merge(parent::buildPayload(), [
            'enable_incomplete_payments' => true
        ]);
    }
}

I've got payment intents mostly working locally now with current version of Cashier. Still a work in progress (understanding the hooks etc), but it's looking good so far.

bilfeldt commented 5 years ago

Just wanted to share information about upcoming changes being added to Stripe on Juli 1, 2019 regarding Subscriptions.

Further changes needed for subscription in EU

If you are based in Europe and preparing for Strong Customer Authentication (SCA) is a new regulatory requirement coming into effect on September 14, 2019 which will impact many European online payments. It requires customers to use two-factor authentication like 3D Secure to verify their purchase, you will need to make further changes after July 1 in order to perform authentication when saving a card for subsequent off-session payments to qualify for off-session exemptions. This API will be available by July 1. Source

driesvints commented 5 years ago

@bilfeldt thanks for noting that. I'll create a separate issue for this. We'll release an update for this once the API has been released.

driesvints commented 5 years ago

I've been working on a PR for this and it's coming along nicely. Follow along here: https://github.com/laravel/cashier/pull/667

driesvints commented 5 years ago

The PR was merged 🎉

damayantinama commented 5 years ago

I too am looking into this, not only as a way to be SCA compliant but also to help prevent bank disputes. It's a pretty critical feature.

The flow seems a bit ambiougs to me. If anyone can make sense of it / clarify could you comment? It would also help Cashier implement a flow that works with plans/subscriptions.

The Payment Intent Flow (as I understand it) - using stripe js / php and with a subscription / plan

  1. You create a payment intent on server side when you hit the checkout page (and store this in session for future use) using the details of your plan:
$plan = \Stripe\Plan::retrieve('your-subscription-plan-id', [
    'api_key' => config('services.stripe.secret'),
]);

$paymentIntent = \Stripe\PaymentIntent::create([
    'amount'               => $plan->amount,
    'currency'             => $plan->currency,
    'payment_method_types' => ['card'],
], [
    'api_key' => config('services.stripe.secret'),
]);

You can then pass the payment intent details onto your view / form so that it can be used in js when you eventually make a call to handleCardPayment

  1. In javascript, you collect card details / mount etc in the usual way and instead of making a call to stripe.createToken() instead you call stripe.handleCardPayment():
stripe.handleCardPayment(document.querySelector('input[name="payment_intent_secret"]').value, card).then(function(result) {
    if (result.error) {
        // Payment failed.
    } else {
        // Payment successful.
    }
});
  1. This is where I get a bit confused, but I'm assuming at that point if the payment was successful, you can create a token for the user and susbcribe them to the plan. I would imagine you would want it to skip the first payment as you have already paid for the first month in the payment intent? Maybe this is where trial period comes into play? I'm also not sure where the web hook comes into play here, as I would assume if the callback is successful then you don't need to do any further checks?
  2. At that point on the server you can clear the payment intent - maybe this is where the web hook comes into play? Is it needed though when you can just get the payment intent when you hit the checkout page, check its status to see if its "expired" and instead use a new one:
$paymentIntent = null;
if (session('paymentIntentId')) {
    $paymentIntent = \Stripe\PaymentIntent::retrieve(session('paymentIntentId'), [
        'api_key' => config('services.stripe.secret'),
    ]);
}

$expiredPaymentIntent = $paymentIntent && in_array($paymentIntent->status, ['canceled', 'succeeded']);

if (! $paymentIntent || $expiredPaymentIntent) {
   // Create new payment intent here, as its either not created or old one has expired.
   // ... 
   // ..
   // Remember it in session so we don't create a new payment intent everytime checkout page is reloaded:
   session()->put('paymentIntentId', $paymentIntent->id, now()->addDay());
}

If anyone can shed light on the flow / clarify how they've got it working in their app that would be much appreciated.

show this error after upgrade cashier 10 from 9

image

How to solve it?

Thanks in advance!

u01jmg3 commented 5 years ago

From the upgrade guide:

The useCurrency method has been replaced by a configuration option in the new Cashier configuration file and the usesCurrency method has been removed.

./config/cashier.php: https://github.com/laravel/cashier/blob/10.0/config/cashier.php#L73


💡 I would suggest reading the whole of the upgrade guide as there could be other gotchas.

damayantinama commented 5 years ago

usesCurrency

for this Url not geeting where and which one file nedd to change.

damayantinama commented 5 years ago

may this problem create cashier 10 and spark 7+

driesvints commented 5 years ago

I've already asked you twice to please ask this on a support channel. Please read the upgrade guide thoroughly.