Tutorial

Setup Stripe with Phoenix LiveView

liveview stripe forms payments

In this tutorial, I will go through how I setup Stripe payments with Phoenix and LiveView to make your app prepared for accepting payments. The tutorial includes working process for Strong Customer Authentication (SCA).

I combine Phoenix LiveView and Stripe Elements to create a self hosted customized checkout. I think its particularly suitable since it involves async communication with an external service.

Keep in mind that this is a get-started tutorial and does not go into product subscriptions, which will come in a follow up tutorial or course.

STEP 1 - Get a Stripe account

If you don't already have an account, head over to Stripe (https://stripe.com/) and sign up.

STEP 2 - Prepare your app

I want to store the checkouts in the database for convenience. In this tutorial, I don't associate them with the users table but have a specific checkouts context and table. The important thing to store is the paymentintentid.

# mix.exs
mix phx.gen.context Checkouts Checkout checkouts email name amount:integer currency payment_intent_id payment_method_id status

And then run migrations with:

mix ecto.migrate

After I have run the migrations, there is only one thing that I need to change. I want to remove some of the values that are required in the changeset so only email and name is required. I will use that for form validation in the checkout form

# lib/shop_test/checkouts/checkout.ex
def changeset(checkout, attrs) do
  checkout
  |> cast(attrs, [:email, :name, :amount, :currency, :payment_intent_id, :payment_method_id, :status])
  |> validate_required([:email, :name])
end

When that is done, I am ready for installing and setup Stripe.

STEP 3 - Install Stripe for Elixir

I use the awesome package "stripity_stripe". Add it you mix file by

# mix.exs
{:stripity_stripe, "~> 2.0"}

And install it by running:

mix deps.get

There is only one configuration that is required to get started. And that is the api_key. An important thing to note, is that stripe supports development and production. There are different API keys so, in development, make sure you insert the development key.

# config/config.exs
config :stripity_stripe, api_key: System.get_env("STRIPE_SECRET")

I get the STRIPE_SECRET from the Stripe dashboard. I can find the secret key if I click "Reveal test key token"

Then I can add it to my .env-file

# .env
export STRIPE_SECRET=XXXXXXXXXXXX

NOTE that its really important that you don't share this or make it public in any way. So make sure you don't commit it in source control.

Setup frontend with LiveView

Now the code will be a little complex. I want to

  1. I want to render an initial form with email and name
  2. When that is valid and submitted, I want to render the credit card field
  3. When the payment is successful, I want to render a success message

The initial code in the LiveView is:

defmodule ShopTestWeb.CheckoutLive.New do
  use ShopTestWeb, :live_view

  alias ShopTest.Checkouts
  alias ShopTest.Checkouts.Checkout

  @impl true
  def mount(_params, _session, socket) do
    {
      :ok,
      socket
      |> assign(:changeset, Checkouts.change_checkout(%Checkout{}))
      |> assign(:checkout, nil)
      |> assign(:intent, nil)
    }
  end
end

NOTE that intent will later hold a PaymentIntent object. That is the key object in Stripe for tracking a payment.

The inital form

<%= f = form_for @changeset, "#", phx_submit: "submit" %>
  <%= hidden_input f, :amount, value: "1099" %>
  <%= hidden_input f, :currency, value: "USD" %>

  <%= label f, :email, class: "tag-label mb-4" do %>
    <span>Your Email</span>
    <%= text_input f, :email, class: "tag-input", placeholder: "Ex. james@bond.com", disabled: !is_nil(@checkout) %>
    <span class="tag-validation"><%= error_tag f, :email %></span>
  <% end %>
  <%= label f, :name, class: "tag-label mb-4" do %>
    <span>Your Name (same as your credit card)</span>
    <%= text_input f, :name, class: "tag-input", placeholder: "Ex. James Bond", disabled: !is_nil(@checkout) %>
    <span class="tag-validation"><%= error_tag f, :name %></span>
  <% end %>

  <%= if is_nil(@checkout) do %>
    <button type="submit" class="btn btn-icon btn-primary w-full">
      <svg class="mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z" />
        <path fill-rule="evenodd" d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z" clip-rule="evenodd" />
      </svg>
      Go to payment
    </button>
  <% end %>
</form>

NOTE There are card coded values for amount and currency as hidden fields. Also note that an amount of 1099 is $10.99

It will look like:

The code for handling the submit looks like:

@impl true
def handle_event("submit", %{"checkout" => checkout_params}, socket) do
  case Checkouts.create_checkout(checkout_params) do
    {:ok, checkout} ->
      send(self(), {:create_payment_intent, checkout}) # Run this async

      {:noreply, assign(socket, checkout: checkout, changeset: Checkouts.change_checkout(checkout))}
    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

If the form is valid, I want to start the process of creating the Stripe PaymentIntent. I call it above by:

send(self(), {:create_payment_intent, checkout})

And handle that in the function:

@impl true
def handle_info({:create_payment_intent, %{email: email, name: name, amount: amount, currency: currency} = checkout}, socket) do
  with {:ok, stripe_customer} <- Stripe.Customer.create(%{email: email, name: name}),
       {:ok, payment_intent} <- Stripe.PaymentIntent.create(%{customer: stripe_customer.id, amount: amount, currency: currency}) do

    # Update the checkout
    Checkouts.update_checkout(checkout, %{payment_intent_id: payment_intent.id})

    {:noreply, assign(socket, :intent, payment_intent)}
  else
    _ ->
      {:noreply, assign(socket, :stripe_error, "There was an error with the stripe")}
  end
end

NOTE That I create both a Stripe Customer and PaymentIntent and also update my checkout record.

In case this was successful, I want to tell the form that It can now render the credit card form. I will also initialize the Stripe Elements javascript with a LiveView webhook.

So, in top of the view file I need to add:

<script src="https://js.stripe.com/v3/"></script>

And below the other form, add:

<%= if @intent do %>
  <form action="#" method="post" data-secret="<%= @intent.client_secret %>" phx-hook="InitCheckout" id="payment-form">
    <div class="form-row mb-4">
      <label for="card-element" class="tag-label">
        Credit or debit card
      </label>
      <div id="card-element" class="tag-input">
        <!-- A Stripe Element will be inserted here. -->
      </div>

      <!-- Used to display form errors. -->
      <div id="card-errors" class="tag-label" role="alert"></div>
    </div>

    <button class="btn btn-primary w-full">Submit Payment</button>
  </form>
<% end %>

NOTE that I received a secret token from the PaymentIntent, that is needed in the JS to reference the correct PaymentIntent.

The LiveView webhook looks like:

// app.js
import {InitCheckout} from "./init_checkout"

let Hooks = {}

Hooks.InitCheckout = InitCheckout

And init_checkout.js

// The public key can be found in the Stripe Dashboard
const stripe = Stripe('pk_test_51HSLYOJuBzfbzD5Jra9Sy7DhnZxBmoLU6jLEevb7YNcMa2QUGBZoiAC254s0pNbuxYWDj1OZ4IScKanyvFv2ahw700wbNW6oza')

export const InitCheckout = {
  mounted() {
    const successCallback = paymentIntent => { this.pushEvent('payment-success', paymentIntent) }
    init(this.el, successCallback)
  }
}

const init = (form, successCallback) => {
  const clientSecret = form.dataset.secret

  // Create an instance of Elements.
  var elements = stripe.elements();

  // Create an instance of the card Element.
  var card = elements.create('card', {style: style});

  // Add an instance of the card Element into the `card-element` <div>.
  card.mount('#card-element');

  // Handle real-time validation errors from the card Element.
  card.on('change', function(event) {
    var displayError = document.getElementById('card-errors');
    if (event.error) {
      displayError.textContent = event.error.message;
    } else {
      displayError.textContent = '';
    }
  });

  // Handle form submission.
  form.addEventListener('submit', function(event) {
    event.preventDefault()

    stripe.confirmCardPayment(clientSecret, {
      payment_method: {
        card: card
      }
    }).then(function(result) {
      if (result.error) {
        // Show error to your customer (e.g., insufficient funds)
        console.log(result.error.message);
      } else {
        // The payment has been processed!
        if (result.paymentIntent.status === 'succeeded') {
          // Show a success message to your customer
          successCallback(result.paymentIntent)
        }
      }
    })
  })
}

// Custom styling can be passed to options when creating an Element.
// (Note that this demo uses a wider set of styles than the guide below.)
const style = {
  base: {
    color: '#32325d',
    fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
    fontSmoothing: 'antialiased',
    fontSize: '16px',
    '::placeholder': {
      color: '#aab7c4'
    }
  },
  invalid: {
    color: '#fa755a',
    iconColor: '#fa755a'
  }
}

NOTE In a non-LiveView world, this would communicate with Stripe, and then redirect to a success page. Now, I can add this callback:

const successCallback = paymentIntent => { this.pushEvent('payment-success', paymentIntent) }

And push the result back to the LiveView process and handle it with:

def handle_event("payment-success", %{"payment_method" => payment_method_id, "status" => status}, socket) do
  checkout = socket.assigns.checkout
  # Update the checkout with the result
  {:ok, checkout} = Checkouts.update_checkout(checkout, %{payment_method_id: payment_method_id, status: status})

  {:noreply, assign(socket, :checkout, checkout)}
end

To keep things simple, I will just saddle for showing the success message in the new.html.leex in an if-else

<%= if @checkout && @checkout.status == "succeeded" do %>
  <div class="alert alert-solid-success" role="alert">Your payment went successfully through!</div>
<% else %>
  <script src="https://js.stripe.com/v3/"></script>
  <!-- rest of the code with the forms -->
<% end %>

So, when Stripe Elements is connected, and I have filled out email and name, I should be presented with:

that i can fill in with a test card that I received from Stripes page.

And when I submit, I should get a success message:

Besides that, I should be able to navigate to purchases in Stripes dashboard and see the transaction registered there.

Conclusion

I did make it easy for myself and only handles the success cases. But I plan to make a more comprehensive course on the subject including subscriptions and listening to webhooks.

Related Tutorials

Published 13 May - 2021

How to create a custom select with Alpine JS and Phoenix LiveView

In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoeni..

Published 12 Feb - 2022

CSV Export with Phoenix and LiveView

A common need in web apps is to export data to different file formats. One common, or even maybe the most common format, is exporting CSV files. CSV..