Using Stripe with Laravel/PHP (Stripe connect)

What we’ll cover:

Overview

Enabling Stripe-Connect account

The On-boarding Process

Generating On-boarding Link

On-boarding verification

Taking Payments

Verifying Payments

Creating a Refund

Listening to Refund events

Overview

In this post I’m going to go over utilizing Stripe’s-Connect. I’ll try giving you a basic understand of how things tie together rather than going into details. I’ll also include snippets of code from one of my projects where possible.

The idea is for our platform SixthDesk to facilitate the transactions between its Users and Customers (Without storing their public/secret keys in our system!!).

A User can create an invoice and send its link to their Customer who then makes the payment. This is the basic idea.

Terms:

Platform: Sixthdesk system itself

User: A user of the platform.

Customer: A person/company being invoiced by the User. This is the entity that pays the money.

Please do keep in mind Stripe isn’t supported in every country!

Enabling Stripe-Connect account

First things first, make sure Stripe-Connect is enabled on your account and you’ve completed the profile. (Feel free to use any details you like in Test mode). You’ll also need to make sure that under Connect settings Oauth is enabled and your Platform account has Standard account type set! this will make sure User accounts are also of same type.

The On-boarding Process

Unless our platform has access to User’s account we won’t be able to perform any action on their behalf.

To Onboard a User we’re going to generate a link which points to the Onboarding page hosted by Stripe. This link could appear in their dashboard/settings. Really depends on the application.

On this page they’ll either be asked to create a new account or Onboard with an existing one if it’s of Standard type.

When the Onboarding is finished Stripe will redirect the user back to your application where you will make sure Onboarding is successful (by sending another request to stripe).

Once successful we’ll be allowed to perform certain actions on behalf of the User.

We’ll see how to generate an Onboarding link in a bit so before we go any further it’s worth talking about type of accounts a Stripe User can have.

A User can have two types of accounts: Standard and Express

Account Types

Standard Account

Standard account is where the User has full access to transactions and is free to process charges/disputes through their Dashboard. Their relationship is basically through Stripe (even tho they’re connected to your Platform).

The advantage here is that you Platform isn’t heavily involved, it’s a relationship between the User, the Customer and Stripe. But this does mean that IF the user decides to perform an action through Stripe Dashboard, for example issue refund, your system won’t know about it unless you setup correct webhooks. This can create discrepancies where your data gets quickly outdated.

Another thing to remember is the flow of transactions, with Standard Account we can create Direct charges.

Direct Charge basically means that the funds go directly into User’s account, then the fee is sent to Platform account (if you’re collecting fees!).

We’ll be using Standard account with Direct charge.

Express Account

You can read more about Express Accounts here but we will not be using it. Express accounts give user their own Dashboard where they can access reports and see payouts but the dispute/refunds responsibility lies with the Platform, in this case it’d be SixthDesk!

Generating On-boarding Link

Lets look look at the code now..

// Redirect back uri in stripe configs: https://dashboard.stripe.com/account/applications/settings
   public function getOauthUri()
   {
      $oauthClientId = Config::get('app.oauth_client_id');

      return "https://connect.stripe.com/oauth/authorize?response_type=code&client_id={$oauthClientId}&scope=read_write&redirect_uri=http://sixthdesk.test/oauth-verify&state={$this->user->id}";
   }

The function above is responsible for generating the link that the User will click to begin the Onboarding process.

When the Onboarding is complete Stripe will send the user back to your website in our case: http://sixthdesk.test/oauth-verify. This endpoint will do the final Onboarding verification. I’ll show you the verification code in a bit.

You’ll notice the getOauthUri() method above returns the link with parameters, let me quickly explain each one:

client_id: This is ID of your platform account. It looks like this “ca_LZzaFysSc4k1ktebayERrT6eFayAtFGa”

scope:read_write: The User connecting to your Platform may also connect to other Platforms. If we don’t set “read_write” we’re letting other Platforms access the user data, including data associated with our Platform! Always set read_write scope so other platforms can’t connect to same account!

redirect_uri: This is where the user will be redirect (basically back to your application, you can verify the Onboarding here).

state: It’s an identifier that you can use to find the user in your database or anything else like a token.

capabilities: Not a query parameter but an important idea in general one! What sort of capabilities the User (Connected account) will have will have? Because we’re going to have Standard Accounts and make Direct charges we’ll only need Transfers and Card Payments. User will have recommended capabilities by default. You can see recommended capabilities here: https://dashboard.stripe.com/connect/settings/profile.

On-boarding Verification:

As you can see in URI above we’ve set redirect_uri to http://sixthdesk.test/oauth-verify so stripe will send the user back to this page once onboarding is complete.

Here we grab the “code” posted back from Stripe and send it back for verification. Also now that verification is done we can also store user’s account id as you can see below.

    public function oauthVerify()
    {
        // oauth/test?scope=read_write&code={AUTHORIZATION_CODE}

        $code = request()->get('code');

        $resp = $this->stripeService->createClient()->oauth->token([
            'grant_type' => 'authorization_code',
            'code' => $code,
        ]);

        User::find(request()->get('state'))->update(['stripe_platform_user_acc_id' => $resp->stripe_user_id]);

        return 'Account connected and ready to take payments';
    }

With verification complete you should be able to see this account under Connect tab of your Stripe platform account.

Taking Payments

<div class="container">
   Hello {{$invoice->client->firstname}} {{$invoice->client->lastname}},

   <p>Please pay your due invoice below. We'll send you an email confirming the payment. <p>

         <html>
         <h1> Due: {{$due}} </h1>

         <!-- Display a payment form -->
         <form id="payment-form">
            <div id="payment-element">
               <!--Stripe.js injects the Payment Element-->
            </div>
            <button id="submit" class="btn btn-primary">
               <div class="spinner hidden" id="spinner"></div>
               <span id="button-text">Pay now</span>
            </button>
            <div id="payment-message" class="hidden"></div>
         </form>

         @foreach($invoice->items as $item)
         {{$item->name}}
         @endforeach
  </div>

Frontend Javascript

This sends a POST request to backend’s /payment-intent/{token} which creates a Payment Intent token with correct due amount.

Then if the payment goes through we send a POST request to /payment-successful to verify it and update any database record.

document.querySelector("#payment-form").addEventListener("submit", handleSubmit);

   const stripe = Stripe("{{$stripePlatformPubKey}}", {
      stripeAccount: "{{$stripePlatformUserAccId}}"
   });

   let elements;

   initialize();

   async function initialize() {
      const {
         clientSecret
      } = await fetch("/payment-intent/{{$invoice->link_token}}", {
         method: "POST"
         , headers: {
            "Content-Type": "application/json"
         }
      , }).then((response) => response.json());

      elements = stripe.elements({
         clientSecret
      });

      const paymentElement = elements.create("payment");
      paymentElement.mount("#payment-element");
   }

   // When submitted, POST to backend to take payment.
   async function handleSubmit(e) {
      e.preventDefault();

      const {
         error
      } = await stripe.confirmPayment({
         elements
         , confirmParams: {
            return_url: "http://sixthdesk.test/payment-successful"
         , }
      , });

      if (error.type === "card_error" || error.type === "validation_error") {
         showMessage(error.message);
      } else {
         showMessage("An unexpected error occurred.");
      }
   }

Creating a Payment Intent Token

Lets create a payment intent and return to browser (response to /payment-intent/{token} request)

public function createPaymentIntent($linkToken)
    {
        $invoice = Invoice::where('link_token', $linkToken)->first();
        $stripePlatformUserAccountId = $invoice->client->user->stripe_platform_user_acc_id;
        
        $totalDueLocal = $invoice->getDue();
        $totalDueStripe = $invoice->getDue() * 100;

        $unpaidPayment = $invoice->unpaid()->first();

        if ($unpaidPayment) { // We have to make sure, whatever the Client sees is tied to right amount. (in cases where User changes the amount after sending invoice)
            $paymentIntent = $this->stripeService->createClient()->paymentIntents->update($unpaidPayment->getPaymentIntentId(), [
                'amount' => $totalDueStripe,
            ], ['stripe_account' => $stripePlatformUserAccountId]);

            $unpaidPayment->update(['amount' => $invoice->getDue(), 'status' => $paymentIntent->status]);
        } else {
            $paymentIntent = $this->stripeService->createClient()->paymentIntents->create([
                'amount' => $totalDueStripe,
                'currency' => 'gbp',
            ], ['stripe_account' => $stripePlatformUserAccountId]);
            
            Payment::create(['amount' => $totalDueLocal, 'invoice_id' => $invoice->id, 'payment_type_id' => 1, 'stripe_payment_intent_id' => $paymentIntent->id, 'status' => $paymentIntent->status]);
        }

        return response()->json(['clientSecret' => $paymentIntent->client_secret]);
    }

We’re essentially creating an intent to charge the user whatever is due. We’re then returning the token to Frontend to handle the rest (entering card details and all).

An important variable is $stripePlatformUserAccountId this is id of the connected account! as you can see, we’re taking payment on behalf of the user using their account ID but platform’s client and secret key.

Verifying Payments

As we know /payment-successful endpoint is triggered by Javascript when stripe responds back so to make sure payment was actually successful and update any data if needed we do below:

public function paymentSuccessfulPage()
    {
        $paymentIntentId = request()->get('payment_intent');
        $payment = Payment::getByIntent($paymentIntentId)->first();
        $platformUserAccId = Payment::getStripePlatformUserAccId($payment->id);
        $stripePlatformPubKey = config('app.stripe_platform_pub_key');

        $intent = $this->stripeService->createClient()->paymentIntents->retrieve(
            $paymentIntentId, [], ['stripe_account' => $platformUserAccId]
        );

        if($intent->status == 'succeeded') {
            $payment->update(['status' => $intent->status]);

            $payment->invoice->update(['status' => $this->invoiceStatusService->setInvoice($payment->invoice)->getInvoiceStatus()]); // paid only if its full payment, else partially_paid
    
            return view('payment_successful', ['status' => $intent->status, 'stripePlatformPubKey' => $stripePlatformPubKey, 'stripePlatformUserAccId' => $platformUserAccId]);
        }

        die('payment object didnt return succeeded, returned: ' . $payment->status);
    }

Creating a Refund

A User has functionality to issue refund to a Customer. It’s a single HTML page with some validation where user can see list of payments they’ve received, they can issue partial or full refund.

The refundPayment() method below receive a POST request from frontend and invokes refund() passing the Payment object along with amount.

public function refundPayment(Request $req, Payment $payment)
    {
        if (!Gate::allows('manage-invoice', $payment->invoice)) {
            abort(404);
        }

        $amount = $req->get('amount');
        $reason = $req->get('reason');

        return $this->stripeRefundService->refund($payment, $amount, $reason);
    }
public function refund(Payment $payment, int $amount, $reason)
   {
      $refundable = $this->refundAmountIsWithinLimit($payment->id, $amount);

      if (!$refundable) {
         return response()->json(['success' => false, 'message' => 'Requested amount must be smaller than or equal to whats refundable']);
      }

      try {
         $resp = $this->sendRefundRequest($payment, $amount, $reason);
         $status = $resp->status; // can be pending, succeeded or failed https://stripe.com/docs/api/refunds/object#refund_object-status
         $chargeId = $resp->charge;

         $paymentRefund = PaymentRefund::create(['payment_id' => $payment->id, 'amount' => $amount, 'reason' => $reason, 'stripe_charge_id' => $chargeId, 'status' => $status]);

         if ($status == 'succeeded') {
            return response()->json(['success' => true, 'message' => 'Refund issued']);
         } elseif ($status == 'pending') {
            return response()->json(['success' => true, 'message' => 'Refund is pending. Please make sure your Stripe account has enough balance.']);
         } else {

            $paymentRefund->delete();

            \Log::error('Return has returned following status: ' . $resp->status . ' Payment id: ' . $payment->id . ' Charge id: ' . $chargeId);

            return response()->json(['success' => false, 'message' => 'Unexpected response from Stripe. Please contact SIG support']);
         }
      } catch (\Exception $e) {
         \Log::error($e);

         return response()->json(['success' => false, 'message' => 'Exception error occured. Unexpected response from Stripe. Please contact SIG support']);
      }
   }

Here we’re finally sending request to stripe, I recommend you always store payment intent id as it can be used to perform many things!

protected function sendRefundRequest($payment, $amount, $reason)
   {
      return $this->createClient()->refunds->create(
         ['payment_intent' => $payment->getPaymentIntentId(), 'amount' => $amount * 100, 'reason' => $reason],
         ['stripe_account' => $payment->getUser()->stripe_platform_user_acc_id]
      );
   }

Listening to Refund Events

A user connected to the platform can always open their dashboard and issue refund. To make sure your application is performing necessary actions upon successful refund (ie. updating data, hiding info from user, triggering emails etc..), you’ll need to listen to Refund event send by Stripe. Note that refunds aren’t processed instantly!

public function postbackUpdateRefundStatus(Request $req)
    {
        $payload = @file_get_contents('php://input');

        $event = null;

        try {
            $event = \Stripe\Event::constructFrom(
                json_decode($payload, true)
            );
        } catch (\UnexpectedValueException $e) {
            echo 'Webhook error while parsing basic request.';
            http_response_code(400);
            exit();
        }

        $endpointSecret = $this->stripeRefundService->getWebhookSec();

        if ($endpointSecret) {
            $sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'];
            try {
                $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $endpointSecret);
            } catch (\Stripe\Exception\SignatureVerificationException $e) {
                echo 'Webhook error while validating signature.';
                http_response_code(400);
                exit();
            }

            $invoice = $this->stripeRefundService->updateRefundStatus($event)->getInvoice();
            $invoice->update(['status' => $this->invoiceStatusService->setInvoice($invoice)->getInvoiceStatus()]);

            http_response_code(200); // Tell stripe to stop sending anymore event for this refund
        }
    }
public function updateRefundStatus($event)
   {
      $status = $event->data->object->status;
      $failureMessage = null;

      switch ($event->type) {
         case 'charge.refunded':
            $chargeId = $event->data->object->id;
            // Charge can only be pending when issuing refund (ie. when application sends the request for reason to stripe)
            // Charge transitioning from succeeded/pending to refunded
            break;
         case 'charge.refund.updated':
            // Charge transitioning from succeeded/pending to failed
            $chargeId = $event->data->object->charge;
            $failureMessage = $event->data->object->failure_message;
            break;
         default:
            // Shoudn't receive any other event as set in Stripe
            \Log::info('Received unknown event type: ' . $event->type, [$event]);

            return false;
      }

      PaymentRefund::where('stripe_charge_id', $chargeId)->firstOrFail();

      PaymentRefund::where('stripe_charge_id', $chargeId)->update(['status' => $status, 'failure_message' => $failureMessage]);

      return PaymentRefund::where('stripe_charge_id', $chargeId)->first();
   }

If you’ve made it this far, congratulation! hope it was useful!


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *