The Simplest Possible Stripe Subscriptions Setup with Phoenix and stripity-stripe

Thomas Hansen

Currently tolc has an excellent guide on doing just what I'm about to describe, and my guide will be very similar. However since that guide was written a couple things have changed (such as the Route package is now deprecated) and I thought it could use some more context. Additionally his guide just uses Stripe BillingPortal, which I found provides a less intuitive experience compared to Stripe.Checkout.

Additionally (and crucially) my guide doesn't have webhooks. Stripe really wants you to use them, and you probably should, but they're not necessary to start playing around with the Stripe API and management panel.

What we'll be building

This will be a minimal guide on setting up the stripity-stripe dependency to accept a subscription payment links on Stripe. The bulk of the code lies in the Stripe.Checkout API, which allows you to create a checkout session for a given product, and then receive information as a response to the session. If you do not need to integrate into a site you can instead just use Stripe Payment links (no code or webhooks but the possibility of adding later). Other solutions like Big Commerce allow you to set up a complete marketplace, while Gumroad makes it easy to sell just one item. A million custom solutions like this exist, so I won't get into it, but they can be considered.

Prerequisites

I have shamelessly stolen the tolc guide's prerequisites and placed them here:

  1. A Stripe account
  2. A Stripe API (test) key
  3. A Stripe webhook (test) secret
  4. Create a Stripe product
  5. A Stripe price id for that product (looks like price_xx...)
  6. The Stripe CLI (just for testing)
  7. An Elixir Phoenix project

Setup

I used version 3.1.1, and it will have to be added to your mix.exs file like so:

{:stripity_stripe, "~> 3.1.1"}

You will also have to add API keys, and in phoenix that's usually done with environment variables. In my dev.exs file I added

# config/dev.exs

# Stripe API keys 
stripe_api_key = System.get_env("STRIPE_API_SECRET") ||
    raise """
    environment variable STRIPE_API_KEY is missing.
    You can obtain it from the stripe dashboard: https://dashboard.stripe.com/test/apikeys
    """

stripe_webhook_key = System.get_env("STRIPE_WEBHOOK_SIGNING_SECRET") ||
    raise """
    environment variable STRIPE_WEBHOOK_SIGNING_SECRET is missing.
    You can obtain it from the stripe dashboard: https://dashboard.stripe.com/account/webhooks
    """

config :stripity_stripe,
  api_key: stripe_api_key,
  signing_secret: stripe_webhook_key,
  stripe_price_id: "price_..."

Which also includes my price_id. Your price id is essentially the subscription the user will subscribe to, and in my case I only had one. You will need different price_id values for development and production, so I put mine in my dev.exs and prod.exs. It could be wise to create a product schema which contains your price id's and such, however to keep it as simple as possible I put mine here.

Although not necessary, you may also want to add a base_url variable. Verified routes don't populate into full url's in json packets, and so I used a separate function to build a url when I needed too, which utilized this variable. I had this in my dev.exs and prod.exs.

config :parentcontrolswin, base_url: "https://www.your-url.com"

Locally I load my environment variables using a .env file, which is loaded with source .env

export STRIPE_API_SECRET="sk_test_..."
export STRIPE_WEBHOOK_SIGNING_SECRET="whsec_..."

Creating Stripe Subscriptions

Now the real fun begins. When a user tries to subscribe, Stripe first creates an "account" for them and returns a stripe_customer_id for us to save, which identifies them in future requests. I added stripe_customer_id to my user migration, making it a field I can save to user. In the example code below, I used Pow authentication library to manage login tokens, but I avoided using their Pow.Plug.current_user(conn) function, since the customer id may still be cached as nil and may cause issues at runtime (which is why I requery it with get_stripe_customer_id_from_email(user.email)).

The bulk of the work is done in the subscription controller. Since Phoenix is moving away from the Routes Helper and towards verified routes, there isn't a good way to input the return path. For me I just saved the full return path URL or used my custom build_external_url function to construct a return path.

One thing that is notable is since we're not using webhooks, I have to ping the Stripe server every time I want to check if someone is subscribed with the is_subscribed? function. This is suboptimal for a program of any scale or if you need to frequently check whether they're subscribed.

# lib/my_app_web/controllers/subscription_controller.ex
defmodule MyAppWeb.SubscriptionController do
    use MyAppWeb, :controller
    alias Stripe

    def index(conn, _params) do
        render(conn, :index)
    end

    def success(conn, _params) do
        conn
        |> put_flash(:info, "Thanks you for subscribing! Don't hesitate to contact us if you have any questions!")
        |> redirect(to: ~p"/install_pcw")
    end

    def cancel(conn, _params) do
        conn
        |> put_flash(:error, "Your attempt to subscribe was canceled; are not subscribed.")
        |> redirect(to: ~p"/subscriptions")
    end

    # This function will check if the customer already has a stripe_customer_id. If they don't, we create one. If they do, we 
    # attempt to check them out if they're not currently subscribed.
    def new(conn, %{}) do
        # Or if it is a recurring customer, you can provide customer_id
        user = Pow.Plug.current_user(conn)
        customer_id = stripe_customer_id(conn, user)
        
        # Get this from the Stripe dashboard for your product
        price_id = Application.get_env(:stripity_stripe, :stripe_price_id)
        coupon_id = Application.get_env(:stripity_stripe, :stripe_coupon_id)
        quantity = 1

        # should never be empty after stripe_customer_id
        if customer_id in [nil, ""] do
            conn
            |> put_flash(:error, "Error finding your customer id. Please try again or contact support. #{customer_id}")
            |> redirect(to: ~p"/subscriptions")
        end

        checkout_session = %{
            payment_method_types: ["card"],
            customer: customer_id,
            mode: "subscription",
            line_items: [%{
                price: price_id,
                quantity: quantity
            }],
            success_url: build_external_url("/subscriptions/new/success"),
            cancel_url: build_external_url("/subscriptions/new/cancel")
        }
        
        session = case Stripe.Checkout.Session.create(checkout_session) do
            {:ok, session} ->
                session
            {:error, stripe_error} ->
                conn
                |> put_flash(:error, "Something went bringing you to checkout; #{stripe_error.message}")
                |> redirect(to: ~p"/subscriptions")
        end

        redirect(conn, external: session.url)

        case Stripe.Checkout.Session.create(checkout_session) do
            {:ok, session} ->
                redirect(conn, external: session.url)
            {:error, stripe_error} ->
                conn
                |> put_flash(:error, "Something went wrong building your checkout portal, #{stripe_error.message}")
                |> redirect(to: ~p"/subscriptions")
        end
    end

    # This includes logic to make sure a user has a stripe account, and if so it will 
    # redirect them to the Stripe Management Portal
    def edit(conn, %{}) do
        user = Pow.Plug.current_user(conn)
        customer_id = get_stripe_customer_id_from_email(user.email)
        if customer_id in [nil, ""] do
            conn
            |> put_flash(:error, "You must subscribe first before viewing Stripe Account Management. #{customer_id}")
            |> redirect(to: ~p"/subscriptions")
        end

        billing_page = Stripe.BillingPortal.Session.create(%{
            customer: customer_id,
            return_url: build_external_url("/devices")
        })

        case billing_page do
            {:ok, session} ->
                redirect(conn, external: session.url)

            {:error, stripe_error} ->
                conn
                |> put_flash(:error, "Something went wrong with edit. #{stripe_error.message}")
                |> redirect(to: ~p"/")
        end
    end

    # check if a customer id exists, and if so return a new one
    defp stripe_customer_id(conn, user) do
        # trying to solve a caching issue with Pow
        stripe_customer_id = get_stripe_customer_id_from_email(user.email)

        if stripe_customer_id in [nil, ""] do
            new_customer = %{
                email: user.email,
                description: "Subscription user"
            }

            stripe_customer_id = case Stripe.Customer.create(new_customer) do
                {:ok, stripe_customer} -> 
                    stripe_customer.id
                {:error, stripe_customer_error} -> 
                    conn
                    |> put_flash(:error, "Error getting stripe_customer_id. Error: #{stripe_customer_error.message}")
                    |> redirect(to: ~p"/subscriptions")
                    nil
            end

            # create changeset for user
            changeset = MyApp.Users.User.update_stripe_customer_id_changeset(user, %{stripe_customer_id: stripe_customer_id})
            case MyApp.Repo.update(changeset) do
                {:ok, updated_user} -> 
                    IO.inspect(updated_user)
                    # Handle success, maybe return the updated user or a success message
                {:error, _changeset} -> 
                    conn
                    |> put_flash(:error, "Error setting stripe_customer_id in schema.")
                    |> redirect(to: ~p"/subscriptions")
            end

            stripe_customer_id # return value
        else
            if is_subscribed?(user) do
                conn
                |> put_flash(:info, "Our records show you are currently subscribed! Please contact us if you do not believe this is the case or if you're experiencing issues.")
                |> redirect(to: ~p"/registration/edit")
            else
                # return 
                stripe_customer_id
            end
        end
    end

    # returns customer id from email. Used to avoid Pow caching issues; there may be other ways to resolve this if 
    # you're not using Pow or if you use Pow.Plug.update_user
    defp get_stripe_customer_id_from_email(email) do
        MyApp.Repo.get_by(MyApp.Users.User, email: email).stripe_customer_id
    end

    # returns true if subscribed
    def is_subscribed?(user) do
        customer_id = get_stripe_customer_id_from_email(user.email)

        # defined here for scope
        if customer_id in [nil, ""] do
            # no customer, can't be subscribed
            false
        else
            # case Stripe.Subscription.list(%{customer: customer_id, status: "active"}) do
            case Stripe.Subscription.list(%{customer: customer_id}) do
                {:ok, subscriptions} ->

                    Enum.any?(subscriptions.data, fn subscription ->
                        subscription.status == "active" || subscription.status == "trialing"
                    end)

                    # .data holds all of the subscriptions and metadata
                    # only [] actually matters, but just in case
                    # subscriptions.data not in [nil, "", []]
                {:error, _subscriptions} ->
                    false
            end
        end
    end

    # path MUST have a "/" before it or function won't return valid url
    def build_external_url(path) do
        # third arg is optional, default to production value
        base_url = Application.get_env(:myapp, :base_url, "https://www.your-url.com")
        base_url <> path
    end
end

I of course added custom routes into router.exs

  scope "/", MyAppWeb do
    # TODO: require_auth is a Pow feature to check for sign-in; yours may be different
    pipe_through [:browser, :require_auth]

    # existing routes ...

    get "/subscriptions", SubscriptionController, :index

    post "/subscriptions/new", SubscriptionController, :new
    # return urls, below I set them to flash the user a message and then load the home screen
    get "/subscriptions/new/success", SubscriptionController, :success
    get "/subscriptions/new/cancel", SubscriptionController, :cancel
    # post request will take you externally to the Stripe management portal
    post "/subscriptions/manage", SubscriptionController, :edit
  end

And your subscription post request will look like this:

<%= form_for @conn, ~p"/subscriptions/new", [method: :post], fn f -> %>
    <%= submit "Add Payment and Subscribe", class: "button" %>
<% end %>

While a (technically optional) management post request will look like this:

<%= form_for @conn, ~p"/subscriptions/manage", [method: :post], fn _f -> %>
  <%= submit "Manage Billing", class: "button button-primary" %>
<% end %>

This links you to a page where the user can see and cancel their subscriptions.

Final thoughts

That's basically it! I wanted to keep this as simple as possible, so you can start playing around with more features. Webhooks are of course something you should start thinking about and planning for, however they're not necessary for a functional app. If Stripe requires you make them for your service, you can just set them up and let them go nowhere.

Coupon Codes

Additionally the user cannot enter a coupon code in the payments page if you use Stripe.Checkout.Session. To do this, you have to add a coupon field to discounts, as shown here:

checkout_session = %{
    payment_method_types: ["card"],
    customer: customer_id,
    mode: "subscription",
    discounts: [%{
        coupon: coupon_id
    }],
    line_items: [%{
        price: price_id,
        quantity: quantity
    }],
        success_url: build_external_url("/subscriptions/new/success"),
        cancel_url: build_external_url("/subscriptions/new/cancel")
    }

Additionally you'll have to add the coupon code to your environment variables (or in code)

config :stripity_stripe,
    api_key: stripe_api_key,
    signing_secret: stripe_webhook_key,
    stripe_price_id: "price_...",
    stripe_coupon_id: "********"

And then read the variable in so it can be applied when needed.

coupon_id = Application.get_env(:stripity_stripe, :stripe_coupon_id)
A brief review of Gigalixir

I used Gigalixir for my project, and adding API keys could be added from the terminal which show up in your dashboard.

gigalixir config:set ENV_VARIABLE=foo

This said, I'm not sure I would recommend Gigalixir if you're starting a new project. You must manually migrate their free database to a base tier production database, and the cheapest paid tier they offer starts at $50 a month (the minimum charge to be in the tiers is $10 a month, but your database will be an additional $40-ish before accounting for traffic). The free tier is very nice though, and at scale these costs would become much more reasonable. I haven't looked into Heroku recently but I would on my next Phoenix project.

Please contact me at contact@thomashansen.xyz if you see any issues with this guide, or if you think something could be more clear!