Stripe

Introduction

You can use the IHP Stripe Integration to accept payments within your IHP application.

The IHP Stripe integration is available with IHP Pro and IHP Business. If you’re not on IHP Pro yet, now is a good time to try it out. By switching to Pro, you’re supporting the sustainable development of IHP.

This guide asumes you’ve read through the basics of the stripe documentation to get a gist of how the stripe api works.

Setup

Install ihp-stripe in your IHP app

Add ihp-stripe to the haskellDeps in your default.nix:

let
    ...
    haskellEnv = import "${ihp}/NixSupport/default.nix" {
        ihp = ihp;
        haskellDeps = p: with p; [
            # ...
            ihp-stripe
        ];
    ...

Now you need to remake your environment using make -B .envrc.

Next add import IHP.Stripe.Config to your Config/Config.hs:

module Config where

-- ...

import IHP.Stripe.Config

Add a call to initStripe inside the Config/Config.hs to configure the stripe credentials:

module Config where

import IHP.Prelude
import IHP.Environment
import IHP.FrameworkConfig
import IHP.Stripe.Config

config :: ConfigBuilder
config = do
    option Development
    option (AppHostname "localhost")

    initStripe

Stripe API Keys

Open the start script and add the following env variables:

# Add these before the `RunDevServer` call at the end of the file
export STRIPE_WEBHOOK_SECRET_KEY="whsec_..."
export STRIPE_PUBLIC_KEY="pk_test_..."
export STRIPE_SECRET_KEY=""

# Finally start the dev server
RunDevServer

Replace the whsec_... and pk_test_... with your stripe api keys. It’s best to use test-mode keys here for now. You can get them on the stripe dashboard.

Keep the STRIPE_SECRET_KEY empty for now. We’ll get back to that in the next steps.

Subscriptions

In this guide we’ll deal with how to set up a typical SaaS subscription. We also asume you’re using stripe tax to collect VAT.

Plans

Depending on your project you might have multiple pricing plans.

Even if you have only a single plan for now, it’s helpful to model this in our application with a plans table.

Open the Application/Schema.sql and add this:

CREATE TABLE plans (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    name TEXT NOT NULL,
    stripe_price_id TEXT NOT NULL
);

Next go to the stripe dashboard and a new product. Inside the product you need to add prices.

After you’ve created your product and a first price, insert a first plan into the database using the data editor. Make sure to fill the stripe_price_id with the right stripe price id (looks like price_...).

Subscriptions Table

Open Application/Schema.sql and add this subscriptions table:

CREATE TABLE subscriptions (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    user_id UUID NOT NULL,
    starts_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
    ends_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
    is_active BOOLEAN DEFAULT true NOT NULL,
    stripe_subscription_id TEXT NOT NULL,
    plan_id UUID NOT NULL,
    quantity INT DEFAULT 1 NOT NULL
);

ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_ref_plan_id FOREIGN KEY (plan_id) REFERENCES plans (id) ON DELETE NO ACTION;
ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_ref_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE NO ACTION;

Additionally you also need to add the following fields to your users table:

CREATE TABLE users (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    -- ...,

    stripe_customer_id TEXT DEFAULT NULL,
    plan_id UUID DEFAULT NULL,
    subscription_id UUID DEFAULT NULL
);

CREATE INDEX users_stripe_customer_id_index ON users (stripe_customer_id);
CREATE INDEX users_plan_id_index ON users (plan_id);
CREATE INDEX users_subscription_id_index ON users (subscription_id);

ALTER TABLE users ADD CONSTRAINT users_ref_plan_id FOREIGN KEY (plan_id) REFERENCES plans (id) ON DELETE NO ACTION;
ALTER TABLE users ADD CONSTRAINT users_ref_subscription_id FOREIGN KEY (subscription_id) REFERENCES subscriptions (id) ON DELETE NO ACTION;

Checkout Sessions

In stripe a payment process for a subscription is started by creating a checkout session and redirecting the user to the checkout session page.

For dealing with the checkout sessions, we’ll create a new CheckoutSessions controller. Open Web/Types.hs and add this:

data CheckoutSessionsController
    = CheckoutSuccessAction
    | CheckoutCancelAction
    | CreateCheckoutSessionAction
    deriving (Eq, Show, Data)

Next create the Web/Controller/CheckoutSessions.hs file with this content:

module Web.Controller.CheckoutSessions where

import Web.Controller.Prelude

import qualified IHP.Stripe.Types as Stripe
import qualified IHP.Stripe.Actions as Stripe

instance Controller CheckoutSessionsController where
    beforeAction = ensureIsUser

    action CreateCheckoutSessionAction = do
        -- You can later customize this, e.g. you could pass
        -- the planId via a form on the pricing page
        plan <- query @Plan |> fetchOne

        stripeCheckoutSession <- Stripe.send Stripe.CreateCheckoutSession
                { successUrl = urlTo CheckoutSuccessAction
                , cancelUrl = urlTo CheckoutCancelAction
                , mode = "subscription"
                , paymentMethodTypes = ["card"]
                , customer = currentUser.stripeCustomerId
                , lineItem = Stripe.LineItem
                    { price = plan.stripePriceId
                    , quantity = 1
                    , taxRate = Nothing
                    , adjustableQuantity = Nothing
                    }
                , metadata =
                    [ ("userId", tshow currentUserId)
                    , ("planId", tshow plan.id)
                    ]
                }

        redirectToUrl stripeCheckoutSession.url

    action CheckoutSuccessAction = do
        plan <- fetchOne currentUser.planId
        setSuccessMessage ("You're on the " <> plan.name <> " plan now!")

        -- To keep things simple we just redirect the user to the app's start page
        -- after successful subscribing to our plan.
        --
        -- It's best to have a dedicated "payment success" page, where
        -- this action then should redirect to.
        redirectToPath "/"
    
    action CheckoutCancelAction = do
        -- You typically want to redirect the user to the page where the payment process
        -- was started. E.g. `redirectTo PricingAction`.
        --
        -- To keep things simple in the Guide, we redirect to the start page
        -- of the app for now.
        redirectToPath "/"

Open Web/Routes.hs and enable routing:

instance AutoRoute CheckoutSessionsController

Open Web/FrontController.hs and enable the new controller:

-- ADD THIS IMPORT:
import Web.Controller.CheckoutSessions

instance FrontController WebApplication where
    controllers = 
        [ startPage StartpageAction
        -- ...

        -- ADD THIS:
        , parseRoute @CheckoutSessionsController
        ]

Starting a Checkout Session

You should now be able to start a checkout session. For doing that we need to place a Payment button somewhere in your app. The recommended approach is to create a new PricingAction inside your StaticController and then have the following form there:

<form method="POST" action={CreateCheckoutSessionAction} data-disable-javascript-submission={True}>
    <button class="btn btn-primary">Subscribe to Plan</button>
</form>

After you’ve added the button, you can give it a try. Submitting the form will call the CreateCheckoutSessionAction, which will redirect to the stripe payment page.

Receiving Webhooks

To be notified about a new successful subscription to one of our products, we’ll need to set up Webhooks.

Create a new file Web/Controller/StripeWebhook.hs with this content:

module Web.Controller.StripeWebhook where

import Web.Controller.Prelude

import IHP.Stripe.Actions
import IHP.Stripe.Webhook
import IHP.Stripe.Types

instance StripeEventController where
    on CheckoutSessionCompleted { checkoutSessionId, subscriptionId, customer, metadata } = do
        let userId :: Maybe (Id User) = metadata
                |> lookup "userId"
                |> fmap textToId
        let planId :: Id Plan = metadata
                |> lookup "planId"
                |> fmap textToId
                |> fromMaybe (error "Requires plan id")

        user <- fetchOneOrNothing userId
        case user of
            Just user -> do
                subscription <- newRecord @Web.Controller.Prelude.Subscription
                    |> set #userId user.id
                    |> set #stripeSubscriptionId subscriptionId
                    |> set #planId planId
                    |> set #quantity 1
                    |> createRecord

                user
                    |> setJust #subscriptionId subscription.id
                    |> setJust #planId planId
                    |> setJust #stripeCustomerId customer
                    |> updateRecord

                pure ()
            Nothing -> do
                putStrLn "Stripe CheckoutSessionCompleted: CheckoutSession not found."
                pure ()

    on InvoiceFinalized { subscriptionId, stripeInvoiceId, invoiceUrl, invoicePdf, createdAt, total, currency } = do 
        pure () -- We'll handle this later
    on OtherEvent = do
        putStrLn "Skipping OtherEvent"

Next we need to load the routing. Open Web/Routes.hs and add this import:

import IHP.Stripe.Routes

Finally we need to hook this into the FrontController. Open Web/FrontController.hs and add this:

-- ADD THESE IMPORTS:
import Web.Controller.StripeWebhook
import IHP.Stripe.Types

instance FrontController WebApplication where
    controllers = 
        [ startPage StartpageAction
        -- ...
        , parseRoute @SubscriptionsController

        -- ADD THIS:
        , parseRoute @StripeWebhookController
        ]

Now webhooks are ready.

To test it out install the Stripe CLI. Run stripe listen --forward-to localhost:8000/StripeWebhook to forward test-mode stripe events to your app.

On the first start this command will print out a webhook secret key (starting with whsec_). Copy the key and open start. Inside the start script set the export STRIPE_WEBHOOK_SECRET_KEY="" line to this key. After this restart your app.

Handling Unsubscribes

To automatically deal with customers that unsubscribe, add the following handler to Web/Controller/StripeWebhook.hs:

on CustomerSubscriptionUpdated { subscription = stripeSubscription } = do
    maybeSubscription <- query @Web.Controller.Prelude.Subscription
            |> filterWhere (#stripeSubscriptionId, stripeSubscription.id)
            |> fetchOneOrNothing
    case maybeSubscription of
        Just subscription -> do
            subscription 
                |> set #endsAt (if stripeSubscription.cancelAtPeriodEnd
                        then stripeSubscription.currentPeriodEnd
                        else Nothing)
                |> updateRecord
            pure ()
        Nothing -> pure ()

on CustomerSubscriptionDeleted { subscriptionId } = do
    maybeSubscription <- query @Web.Controller.Prelude.Subscription
            |> filterWhere (#stripeSubscriptionId, subscriptionId)
            |> fetchOneOrNothing
    case maybeSubscription of
        Just subscription -> do
            now <- getCurrentTime
            subscription 
                |> set #endsAt now
                |> set #isActive False
                |> updateRecord

            user <- fetch subscription.userId
            user
                |> set #planId Nothing
                |> set #subscriptionId Nothing
                |> updateRecord
            pure ()
        Nothing -> pure ()

This will set the endsAt date on the subscription, will mark the subscription as not active anymore and then sets the planId of the user to null.

Billing Portal

To integrate the Stripe Billing Portal, add an action like this to your app:

-- Add these imports at the top
import qualified IHP.Stripe.Actions as Stripe
import qualified IHP.Stripe.Types as Stripe

-- Then add this action:
    action OpenBillingPortalAction = do
        subscription <- fetchOne currentUser.subscriptionId

        stripeSubscription <- Stripe.send Stripe.RetrieveSubscription { id = subscription.stripeSubscriptionId }

        billingPortal <- Stripe.send Stripe.CreateBillingPortalSession
                { customer = stripeSubscription.customer
                , returnUrl = urlTo StartpageAction -- <- You might need to customize the return url here
                }

        redirectToUrl billingPortal.url

Use a form to link to the billing portal:

<form method="POST" action={OpenBillingPortalAction} data-disable-javascript-submission={True}>
    <button type="submit" class="btn btn-primary">Change Payment Details</button>
</form>

Invoices

While your customers can view their invoices using the stripe billing portal, you still might want to integrate them into your app.

Data Structures

Open Application/Schema.sql and add a invoices table:

CREATE TABLE invoices (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    subscription_id UUID NOT NULL,
    stripe_invoice_id TEXT NOT NULL,
    invoice_url TEXT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,
    invoice_pdf TEXT NOT NULL,
    total INT NOT NULL,
    currency TEXT NOT NULL
);
ALTER TABLE invoices ADD CONSTRAINT invoices_ref_subscription_id FOREIGN KEY (subscription_id) REFERENCES subscriptions (id) ON DELETE NO ACTION;

Webhook Handler

Next open Web/Controller/StripeWebhook.hs and implement the on InvoiceFinalized:

    on InvoiceFinalized { subscriptionId, stripeInvoiceId, invoiceUrl, invoicePdf, createdAt, total, currency } = do
        -- The subscription could not be here if it's e.g. created by some other app in your stripe account
        -- or when it's added by-hand
        subscriptionMaybe <- query @Subscription
            |> filterWhere (#stripeSubscriptionId, subscriptionId)
            |> fetchOneOrNothing

        case subscriptionMaybe of
            Just subscription -> do
                existingInvoice <- query @Invoice
                    |> filterWhere (#stripeInvoiceId, stripeInvoiceId)
                    |> fetchOneOrNothing

                case existingInvoice of
                    Just invoice -> do
                        invoice
                            |> set #invoiceUrl invoiceUrl
                            |> set #createdAt createdAt
                            |> set #invoicePdf invoicePdf
                            |> set #total (fromInteger total)
                            |> set #currency currency
                            |> updateRecord
                        pure ()
                    Nothing -> do
                        invoice <- newRecord @Invoice
                            |> set #subscriptionId subscription.id
                            |> set #stripeInvoiceId stripeInvoiceId
                            |> set #invoiceUrl invoiceUrl
                            |> set #createdAt createdAt
                            |> set #invoicePdf invoicePdf
                            |> set #total (fromInteger total)
                            |> set #currency currency
                            |> createRecord
                        pure ()
                pure ()
            Nothing -> do
                putStrLn "Stripe InvoiceFinalized: Subscription not found."
                pure ()

Now stripe invoices are added to the invoices table whenever they’re available.

Showing Invoices

To make the invoices available to your users, create a Invoices controller. In the InvoicesAction you can query the invoices for a user like this:

    action InvoicesAction = do
        subscriptions <- query @Subscription
            |> filterWhere (#userId, currentUserId)
            |> fetch

        invoices <- query @Invoice
            |> orderByDesc #createdAt
            |> filterWhereIn (#subscriptionId, ids subscriptions)
            |> fetch

        render IndexView { .. }

Here’s an example on how to render the invoices in your view:

renderInvoice :: Invoice -> Html
renderInvoice invoice = [hsx|
    <div class="card d-flex flex-row py-3 px-1 mb-1">
        <div class="col">
            <a>{invoice.createdAt |> date}</a>
        </div>
        <div class="col">
            <a href={invoice.invoiceUrl} target="_blank">Subscription</a>
        </div>
        <div class="col">
            <a>{renderPrice invoice.currency invoice.total}</a>
        </div>
        <div class="col-xs mr-3">
            <a href={invoice.invoicePdf}>Download</a>
        </div>
    </div>
|]

renderPrice :: Text -> Int -> Text
renderPrice "eur" amount = show (fromIntegral(amount)/100) <> "€"
renderPrice "usd" amount = "$" <> show (fromIntegral(amount)/100)
renderPrice code amount = show (fromIntegral(amount)/100) <> toUpper code