Tutorial
Setup Stripe with Phoenix LiveView
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
- I want to render an initial form with email and name
- When that is valid and submitted, I want to render the credit card field
- 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.