Skip to content

Instantly share code, notes, and snippets.

@jacobobryant
Created August 3, 2024 23:14
Show Gist options
  • Save jacobobryant/c133a95efb6304b9e32610869acb4116 to your computer and use it in GitHub Desktop.
Save jacobobryant/c133a95efb6304b9e32610869acb4116 to your computer and use it in GitHub Desktop.
Some examples of using Stripe via Clojure/Biff

These are some examples of using the Stripe API in a Clojure/Biff app, taken verbatim from Yakread. For more details, see the Stripe documentation. For the most part I recommend using the REST API directly rather than going through the Java SDK.

Yakread has an advertising page that lets you connect a credit card via Stripe checkout. The card is charged later, after your advertisement has run.

Here's some of the code for the advertising page:

(defn create-customer! [{:keys [biff/secret user] :as ctx}]
  (let [id (-> (http/post "https://api.stripe.com/v1/customers"
                          {:basic-auth [(secret :stripe/api-key)]
                           :form-params {:email (:user/email user)}
                           :as :json})
               :body
               :id)]
    (biff/submit-tx ctx
      [{:db/doc-type :ad
        :db.op/upsert {:ad/user (:xt/id user)}
        :ad/customer-id id
        :ad/state [:db/default :pending]
        :ad/balance [:db/default 0]
        :ad/recent-cost [:db/default 0]
        :ad/updated-at :db/now}])
    id))

(defn add-payment-method [{:keys [biff/secret biff/base-url user ad] :as ctx}]
  (let [customer-id (or (:ad/customer-id ad)
                        (create-customer! ctx))
        response (http/post "https://api.stripe.com/v1/checkout/sessions"
                            {:basic-auth [(secret :stripe/api-key)]
                             :multi-param-style :array
                             :form-params {:payment_method_types ["card"]
                                           :mode "setup"
                                           :customer customer-id
                                           :success_url (str base-url "/fiddlesticks/payment-method?session-id={CHECKOUT_SESSION_ID}")
                                           :cancel_url (str base-url "/advertise")}
                             :as :json})
        {:keys [url id]} (:body response)]
    (biff/submit-tx ctx
      [{:db/doc-type :ad
        :db.op/upsert {:ad/user (:xt/id user)}
        :ad/session-id id
        :ad/updated-at :db/now}])
    {:status 303
     :headers {"Location" url}}))

(defn receive-payment-method [{:keys [biff/secret biff/db params] :as ctx}]
  (let [{:keys [session-id]} params
        pm (-> (http/get (str "https://api.stripe.com/v1/checkout/sessions/" session-id)
                         {:basic-auth [(secret :stripe/api-key)]
                          :multi-param-style :array
                          :as :json
                          :query-params {:expand ["setup_intent" "setup_intent.payment_method"]}})
               :body
               :setup_intent
               :payment_method)]
    (when-some [ad-id (biff/lookup-id db :ad/session-id session-id)]
      (biff/submit-tx ctx
        [{:db/doc-type :ad
          :db/op :update
          :xt/id ad-id
          :ad/session-id :db/dissoc
          :ad/payment-method (:id pm)
          :ad/card-details (select-keys (:card pm) [:brand :last4 :exp_year :exp_month])
          :ad/updated-at :db/now}])))
  {:status 303
   :headers {"Location" "/advertise"}})

(defn delete-payment-method [{:keys [biff/secret ad] :as ctx}]
  (let [ctx (update ctx :ad dissoc :ad/payment-method :ad/card-details)]
    (biff/submit-tx ctx
      [{:db/doc-type :ad
        :db/op :update
        :xt/id (:xt/id ad)
        :ad/payment-method :db/dissoc
        :ad/card-details :db/dissoc
        :ad/updated-at :db/now}])
    (http/post (str "https://api.stripe.com/v1/payment_methods/"
                    (:ad/payment-method ad)
                    "/detach")
               {:basic-auth [(secret :stripe/api-key)]})
    [:<>
     (view-payment-method ctx)]))

...

(def module
  {:routes [["/fiddlesticks" {:middleware [mid/wrap-signed-in
                                           wrap-ad]}
             ["/payment-method" {:get receive-payment-method
                                 :post add-payment-method
                                 :delete delete-payment-method}]
             ...]]})

When you click "Add a payment method", we hit the add-payment-method request handler which creates a customer object in Stripe and then redirects you to Stripe's checkout page. After you enter your credit card, Stripe redirects you to the receive-payment-method request handler which stores the payment method ID, which is used later for charging your credit card.

(Some of the URLs have fiddlesticks in them because ad blockers don't like AJAX requests that hit URLs that contain the text advertise.)

These are some of the functions I use to submit charges:

(defn create-payment-intent! [{:keys [biff/secret ::charge dry-run]}]
  (let [receipt-email (-> charge :ad.credit/ad :ad/user :user/email)
        params {:amount (:ad.credit/amount charge)
                :currency "usd"
                :confirm true
                :customer (-> charge :ad.credit/ad :ad/customer-id)
                :payment_method (-> charge :ad.credit/ad :ad/payment-method)
                :off_session true
                :description "Yakread advertising"
                :receipt_email receipt-email
                :metadata {:charge_id (str (:xt/id charge))}}]
    (when (some? receipt-email)
      (if dry-run
        (do
          (println "creating payment intent for" (-> charge :ad.credit/ad :ad/title))
          (biff/pprint params)
          (println))
        (biff/catchall-verbose
         (http/post "https://api.stripe.com/v1/payment_intents"
                    {:basic-auth [(secret :stripe/api-key) ""]
                     :headers {"Idempotency-Key" (str (:xt/id charge))}
                     :flatten-nested-form-params true
                     :form-params params}))))))

(defn get-charge-status [{:keys [biff/secret ::charge]}]
  (->> (http/get "https://api.stripe.com/v1/payment_intents"
                 {:basic-auth [(secret :stripe/api-key) ""]
                  :flatten-nested-form-params true
                  :as :json
                  :query-params {:limit 100
                                 :customer (-> charge :ad.credit/ad :ad/customer-id)
                                 :created {:gt (-> charge
                                                   :ad.credit/created-at
                                                   inst-ms
                                                   (quot 1000)
                                                   str)}}})
       :body
       :data
       (filter (fn [{:keys [metadata]}]
                 (= (str (:xt/id charge))
                    (:charge_id metadata))))
       first
       :status))

Yakread also has a premium plan for users where you can pay a monthly subscription to remove ads, also managed by Stripe checkout. Yakread sends you to a page hosted on Stripe where you can enter your credit card and select a plan (or change/cancel your plan if you've previously signed up), then Yakread receives webhook messages from Stripe whenever your plan changes:

(ns com.yakread.feat.app.settings.premium
  (:require [com.biffweb :as biff]
            [com.yakread.middleware :as mid]
            [clj-http.client :as http]
            [clojure.stacktrace :as st]
            [ring.util.request :as ring-req]
            [com.yakread.util :as util]
            [com.yakread.ui :as ui]
            [clojure.tools.logging :as log])
  (:import [com.stripe.net Webhook]))

(defn settings [{:keys [user params] :as ctx}]
  (let [param-plan (some-> (:upgraded params) not-empty keyword)
        [plan active] (if param-plan
                        [param-plan true]
                        [(:user/plan user) (util/plan-active? user)])]
    [:<>
     (ui/heading "Yakread Premium")
     (if active
       [:<>
        [:div
         (if (:user/cancel-at user)
           [:<> "You're on the premium plan until "
            (biff/format-date (:user/cancel-at user) "d MMMM yyyy")
            ". After that, you'll be downgraded to the free plan. "]
           [:<>
            "You're on the "
            (case plan
              :quarter "$30 / 3 months"
              :annual "$60 / 12 months"
              :else "premium")
            " plan. "])
         (biff/form
          {:action "/premium/manage"
           :hx-boost "false"
           :class "inline"}
          [:button.link {:type "submit"} "Manage your subscription"])
         "."]
        [:.h-3]]
       [:<>
        [:div "Upgrade to a premium plan without ads:"]
        [:.h-6]
        [:div {:class '[flex
                        justify-center
                        flex-col
                        sm:flex-row
                        gap-4
                        sm:gap-12
                        items-center]}
         (biff/form
          {:action "/premium/upgrade"
           :hx-boost "false"
           :hidden {:plan "quarter"}}
          [:button {:class '[btn
                             "min-w-[150px]"]}
           "$30 / 3 months"])
         (biff/form
          {:action "/premium/upgrade"
           :hx-boost "false"
           :hidden {:plan "annual"}}
          [:button {:class '[btn
                             "min-w-[150px]"]}
           "$60 / 12 months"])]
        [:.h-6]])
     [:div "Are there other features you'd like to see on the premium plan? "
      [:a.link {:href "/feedback"} "Send me your feedback"] "."]]))

(defn create-customer! [{:keys [biff/secret user] :as ctx}]
  (let [id (get-in (http/post "https://api.stripe.com/v1/customers"
                              {:basic-auth [(secret :stripe/api-key)]
                               :form-params {:email (:user/email user)}
                               :as :json})
                   [:body :id])]
    (biff/submit-tx ctx
      [{:db/doc-type :user
        :db/op :update
        :xt/id (:xt/id user)
        :user/customer-id id}])
    id))

(defn manage [{:keys [biff/base-url biff/secret user] :as ctx}]
  {:status 303
   :headers {"location" (get-in (http/post "https://api.stripe.com/v1/billing_portal/sessions"
                                           {:basic-auth [(secret :stripe/api-key)]
                                            :form-params {:customer (:user/customer-id user)
                                                          :return_url (str base-url "/settings")}
                                            :as :json})
                                [:body :url])}})

(defn upgrade [{:keys [biff/base-url
                       biff/secret
                       user
                       params
                       stripe/quarter-price-id
                       stripe/annual-price-id]
                :as ctx}]
  (if (util/plan-active? user)
    (manage ctx)
    (let [customer-id (or (:user/customer-id user)
                          (create-customer! ctx))
          price-id (if (= (:plan params) "quarter")
                     quarter-price-id
                     annual-price-id)
          session-url (get-in (http/post "https://api.stripe.com/v1/checkout/sessions"
                                         {:basic-auth [(secret :stripe/api-key)]
                                          :multi-param-style :array
                                          :form-params {:mode "subscription"
                                                        :allow_promotion_codes true
                                                        :customer customer-id
                                                        "line_items[0][quantity]" 1
                                                        "line_items[0][price]" price-id
                                                        :success_url (str base-url "/settings?upgraded=" (:plan params))
                                                        :cancel_url (str base-url "/settings")}
                                          :as :json})
                              [:body :url])]
      {:status 303
       :headers {"location" session-url}})))

(defn update-subscription! [{:keys [biff/db
                                    stripe/quarter-price-id
                                    body-params]
                             :as ctx}]
  (let [{:keys [customer items cancel_at]} (get-in body-params [:data :object])
        price-id (get-in items [:data 0 :price :id])
        plan (if (= quarter-price-id price-id)
               :quarter
               :annual)
        user-id (biff/lookup-id db :user/customer-id customer)]
    (biff/submit-tx ctx
      [{:db/doc-type :user
        :db/op :update
        :xt/id user-id
        :user/plan plan
        :user/cancel-at (if cancel_at
                          (java.util.Date. (* cancel_at 1000))
                          :db/dissoc)}])))

(defn delete-subscription! [{:keys [biff/db
                                    stripe/quarter-price-id
                                    body-params]
                             :as ctx}]
  (let [{:keys [customer]} (get-in body-params [:data :object])
        user-id (biff/lookup-id db :user/customer-id customer)]
    (biff/submit-tx ctx
      [{:db/doc-type :user
        :db/op :update
        :xt/id user-id
        :user/plan :db/dissoc
        :user/cancel-at :db/dissoc}])))

(defn stripe-webhook [{:keys [body-params] :as ctx}]
  (log/info "received stripe event" (:type body-params))
  (case (:type body-params)
    "customer.subscription.created" (update-subscription! ctx)
    "customer.subscription.updated" (update-subscription! ctx)
    "customer.subscription.deleted" (delete-subscription! ctx)
    nil)
  {:status 200
   :body ""})

(defn wrap-stripe-event [handler]
  (fn [{:keys [biff/secret headers] :as req}]
    (if (and (= "/stripe/webhook" (:uri req))
             (secret :stripe/webhook-secret))
      (try
       (let [body-str (ring-req/body-string req)]
         (Webhook/constructEvent body-str
                                 (headers "stripe-signature")
                                 (secret :stripe/webhook-secret))
         (handler (assoc req
                         :body (-> body-str
                                   (.getBytes "UTF-8")
                                   (java.io.ByteArrayInputStream.)))))
       (catch Exception e
         (st/print-stack-trace e)
         {:status 400
          :body ""}))
      (handler req))))

(def features
  {:routes [["/premium" {:middleware [mid/wrap-signed-in]}
             ["/upgrade" {:post upgrade}]
             ["/manage" {:post manage}]]]
   :api-routes [["/stripe/webhook" {:post stripe-webhook}]]})

And in the main namespace:

(def handler (-> (biff/reitit-handler {:router router :on-error ui/on-error-page})
                 biff/wrap-base-defaults
                 premium/wrap-stripe-event))

wrap-stripe-event uses the Stripe Java SDK to verify that the webhook message is authentic. It needs the request body as a string. We have to make wrap-stripe-event run before any other middleware because otherwise the body input stream would already be consumed. So we read the input stream, turn it into a string, and then create another input stream for the subsequent middleware.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment