Stripe Connect Platform – Taking payments & issuing Refunds

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

Download a free copy

Overview

In this post I’m going to go over utilizing Stripe’s-Connect. By the end of this post you’ll have a basic understand of how things tie together. I’ll also include snippets of code from one of my projects.

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!).

Sixthdesk is a website where Users can create an invoice and send its link to their Customers to take payments.

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. (For profile free to use any details you like when using Test mode).

You’ll also need to make sure that Oauth is enabled under Connect Settings and your Platform account has Standard account type set! This will ensure that users who connect to your platform are of same User account 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 get around this issue we’ll Onboard the Users.

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 (More about account Types in a bit, just remember we’ll be using Standard account and the users will be asked to use same).

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).

If successful we’ll be allowed to perform certain actions on behalf of the User. If it’s not successful or you try using an account id that’s not connected to your platform you’ll get this error:
Stripe\Exception\PermissionException: The provided key ‘sk_test_***q35e1L’ does not have access to account ‘acct_13STq0MN00k2o7SbX4’ (or that account does not exist). Application access may have been revoked.

We’ll see how to generate the Onboarding link in a bit but before that let’s talk about Stripe Account Types!

A User can have two types of account: Standard / 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 with Platform is through Stripe.

The advantage here is that your Platform isn’t heavily involved, it’s a relationship between the User, the Customer and Stripe.

What does this mean in practice? It means that if the User decides to do something 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 where the funds go directly into User’s account, the fee is then 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 would be SixthDesk.

Generating On-boarding Link

By this point you should have Stripe connect enabled with Standard account set and Oauth for Standard Account enabled. https://dashboard.stripe.com/test/settings/connect

Lets look at the code to generate Onbaording link now.

// Redirect back URI can be set 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 the User can 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 it can be set here https://dashboard.stripe.com/test/settings/connect

I’ll show you the verification code in a bit.

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

client_id: This is client ID of your platform account. It looks like this “ca_LZzaFysSc4k1ktebayERrT6eFayAtFGa”. Find this in Stripe connect settings. https://dashboard.stripe.com/test/settings/connect

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 when the verification us done we should store user’s account id as you can see below. This is account’s id so depending on which account we’re using we’ll be using this when sending requests.

    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

Now that we’re connected we want to create a page where we’ll let display the due amount and let Customers make payment to the Users.

HTML

<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. (validation!). Code to create payment intent token is right below the Javascript snippet!

If the payment goes through (frontend), we’ll 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 above)!

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 (this is possible because of successful onboarding).

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:

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);
    }

The payment will appear in Connected account.

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 various 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 (Standard accounts!).

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 sent 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!

Download a free copy

Leave a comment

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