gocardless-clj

0.4.1


Clojure client library for the GoCardless API

dependencies

org.clojure/clojure
1.5.1
pandect
0.3.0
clj-http
0.7.8
org.clojure/data.json
0.2.4
clj-time
0.6.0



(this space intentionally left almost blank)
 

HTTP client wrapper and other utilities

(ns gocardless-clj.client
  (:require [clojure.walk :refer [stringify-keys]]
            [clj-http.client :as client]
            [cheshire.core :as json]
            [clj-time.core :as time]
            [gocardless-clj.signature :refer [sign-params normalise-params]]))
(defn- ua-string []
  (format "gocardless-clj/v%s" (System/getProperty "gocardless-clj.version")))

Recursively replace all dashes with underscores in all the keys of m.

From clojure.walk/stringify-keys.

(defn- underscorize-keys
  [m]
  (let [f (fn [[k v]] [(clojure.string/replace k "-" "_") v])]
    (clojure.walk/postwalk (fn [x] (if (map? x) (into {} (map f x)) x)) m)))

Join parts with /.

(defn path
  [& parts]
  (clojure.string/join "/" parts))

Generate a nonce for a Connect request.

(def ^:private secure-random (java.security.SecureRandom.))
(defn- generate-nonce
  []
  (-> (java.math.BigInteger. 256 secure-random)
      (.toString 32)))

Produce a correct URL for retrieving resource(s).

(defn api-url
  [account uri]
  (if (.startsWith uri "http")
    uri
    (condp = (:environment account)
      :live (format "https://gocardless.com/api/v1/%s" uri)
      :sandbox (format "https://sandbox.gocardless.com/api/v1/%s" uri))))

Produce a correct connect base URL.

(defn connect-url
  [account resource-type]
  (condp = (:environment account)
    :live (format "https://gocardless.com/connect/%ss/new" resource-type)
    :sandbox (format "https://sandbox.gocardless.com/connect/%ss/new" resource-type)))

Produce a correct connect URL for creating a limit.

(defn new-limit-url
  [account resource-type limit-params]
  (let [url (connect-url account resource-type)
        limit-params (-> limit-params stringify-keys underscorize-keys)
        limit-params (assoc limit-params "merchant_id" (:merchant-id account))
        meta-params (select-keys limit-params ["redirect_uri" "cancel_uri" "state"])
        limit-params (dissoc limit-params "redirect_uri" "cancel_uri" "state")
        base-params {"nonce" (generate-nonce)
                     "timestamp" (str (time/now))
                     "client_id" (:app-id account)
                     resource-type limit-params}
        params (merge base-params meta-params)
        signature (-> params (sign-params (:app-secret account)))
        params (merge {"signature" signature} params)]
    (str url
         "?"
         (-> params normalise-params))))

Worker function for api-get, api-post and api-put.

(defn do-request
  [method url params]
  (let [headers {"Accept" "application/json"
                 "User-Agent" (ua-string)}
        final-params (merge params {:method method
                                    :url url
                                    :headers headers
                                    :as :json})]
    (:body (client/request final-params))))

Do a GET request to the API.

(defn api-get
  ([account uri params]
     (api-get account uri params {:oauth-token (:access-token account)}))
  ([account uri params auth-params]
     (do-request :get
                 (api-url account uri)
                 (merge auth-params {:query-params params}))))

Do a POST request to the API.

(defn api-post
  ([account uri params]
     (api-post account uri params {:oauth-token (:access-token account)}))
  ([account uri params auth-params]
     (do-request :post
                 (api-url account uri)
                 (merge auth-params {:form-params params
                                     :content-type :json}))))

Do a PUT request to the API.

(defn api-put
  ([account uri params]
     (api-put account uri params {:oauth-token (:access-token account)}))
  ([account uri params auth-params]
     (do-request :put
                 (api-url account uri)
                 (merge auth-params {:form-params params
                                     :content-type :json}))))
 

Core API functions

This namespace contains the public API functions for this client library.

At a high level, you can:

  • Look up account details for your merchant
  • Look up customers, bills, subscriptions, pre-authorizations and payouts
  • Generate a URL for a new bill, subscription or a pre-authorization
  • Create a new bill under an existing pre-authorization
  • Confirm a created resource
(ns gocardless-clj.core
  (:require [gocardless-clj.client :as c]
            [gocardless-clj.resources :refer :all]
            [gocardless-clj.signature :refer [sign-params]]))
(def cancel      #'gocardless-clj.protocols/cancel)
(def cancelable? #'gocardless-clj.protocols/cancelable?)
(def retry       #'gocardless-clj.protocols/retry)
(def retriable?  #'gocardless-clj.protocols/retriable?)
(declare customers)
(declare payouts)
(declare bills)
(declare subscriptions)
(declare pre-authorizations)

Resource lookup functions

Every resource has a singular and a plural version. For example the customer function comes in two variants - customer and customers. The singular variant takes an ID of the resource and fetches the resource. The plural is capable of fetching a single resource, or a collection. The singular version is just a convenience.

The plural version takes a params map as the second argument. The map can contain any keys outlined in Filtering and Pagination. The map can be omitted or left empty, in which case the defaults are used.

Retrieve a single customer by their ID.

Example:

(customer account "0K636ZDWM9")
(defn customer
  [account id]
  (customers account {:id id}))

Retrieve merchant's customers or a single customer.

Takes as arguments either the account-map and no other params, or the account-map and a map of params.

Examples:

(customers account)
(customers account {:id "0K636ZDWM9"})
(customers account {:per_page 5 :page 2})
(defn customers
  ([account] (customers account {}))
  ([account {:keys [id] :as params}]
     (if id
       (c/api-get account (c/path "users" id) {})
       (c/api-get account
                  (c/path "merchants" (:merchant-id account) "users")
                  params))))

Retrieve a single payout by the payout ID.

Example:

(payout account "0K636ZDWM9")
(defn payout
  [account id]
  (payouts account {:id id}))

Retrieve merchant's payouts or a single payout.

Takes as arguments either the account-map and no other params, or the account-map and a map of params.

Examples:

(payouts account)
(payouts account {:id "0K636ZDWM9"})
(payouts account {:per_page 5 :page 2})
(defn payouts
  ([account] (payouts account {}))
  ([account {:keys [id] :as params}]
     (if id
       (c/api-get account (c/path "payouts" id) {})
       (c/api-get account
                  (c/path "merchants" (:merchant-id account) "payouts")
                  params))))

Retrieve a single bill by the bill ID.

Example:

(bill account "0K636ZDWM9")
(defn bill
  [account id]
  (bills account {:id id}))

Retrieve merchant's bills or a single bill.

Takes as arguments either the account-map and an ID of a bill, or the account-map and params.

Examples:

(bills account)
(bills account {:id "0K636ZDWM9"})
(bills account {:per_page 5 :page 2})
(defn bills
  ([account] (bills account {}))
  ([account {:keys [id] :as params}]
     (if id
       (-> (c/api-get account (c/path "bills" id) {}) map->Bill)
       (let [path (c/path "merchants" (:merchant-id account) "bills")
             bills (c/api-get account path params)]
         (map map->Bill bills)))))

Retrieve a single subscription by the subscription ID.

Example:

(subscription account "0K636ZDWM9")
(defn subscription
  [account id]
  (subscriptions account {:id id}))

Retrieve merchant's subscriptions or a single subscription.

Takes as arguments either the account-map and no other arguments, or the account-map and a map of params.

Examples:

(subscriptions account)
(subscriptions account {:id "0K636ZDWM9"})
(subscriptions account {:per_page 5 :page 2})
(defn subscriptions
  ([account] (subscriptions account {}))
  ([account {:keys [id] :as params}]
     (if id
       (-> (c/api-get account (c/path "subscriptions" id) {})
           (map->Subscription))
       (let [path (c/path "merchants" (:merchant-id account) "subscriptions")
             subs (c/api-get account path params)]
         (map map->Subscription subs)))))

Retrieve a single pre-authorization by the pre-authorization ID.

Example:

(pre-authorization account "0K636ZDWM9")
(defn pre-authorization
  [account id]
  (pre-authorizations account {:id id}))

Retrieve merchant's pre-authorizations or a single pre-authorization.

Takes as arguments either the account-map and no other arguments, or the account-map and a map of params.

Examples:

(pre-authorizations account)
(pre-authorizations account {:id "0K636ZDWM9"})
(pre-authorizations account {:per_page 5 :page 2})
(defn pre-authorizations
  ([account] (pre-authorizations account {}))
  ([account {:keys [id] :as params}]
     (if id
       (-> (c/api-get account (c/path "pre_authorizations" id) {})
           map->PreAuthorization)
       (let [path (c/path "merchants" (:merchant-id account) "pre_authorizations")
             preauths (c/api-get account path params)]
         (map map->PreAuthorization preauths)))))

Creates a new bill under an existing pre-authorization.

Takes as arguments the account map and a params map containing the amount and the pre-authorization ID.

Example:

(create-bill account {:amount 15.0 :pre_authorization_id "0K636ZDWM9"})

Resource creation functions

(defn create-bill
  [account {:keys [amount pre_authorization_id] :as opts}]
  {:pre [(number? amount)
         (> amount 1.0)
         (string? pre_authorization_id)
         (not (empty? pre_authorization_id))]}
  (let [params (assoc opts :amount (bigdec amount))]
    (-> (c/api-post account "bills" {"bill" params})
        map->Bill)))

Returns the Connect URL for a new Bill.

Required map keys: :amount.

Example:

(new-bill account {:amount 10.0})
(new-bill account {:amount 10.0
                   :name "My new bill"
                   :user {:email "customer1@example.com"
                          :first_name "Joe"
                          :last_name "Bloggs"}})
(defn new-bill
  [account {:keys [amount] :as opts}]
  {:pre [(number? amount)
         (> amount 1.0)]}
  (let [params (assoc opts :amount (bigdec amount))]
    (c/new-limit-url account "bill" params)))

Returns the Connect URL for a new Subscription.

Required map keys: :amount, :interval_length, :interval_unit.

Example:

(new-subscription account {:amount 10.0
                           :interval_length 4
                           :interval_unit "week"})
(new-subscription account {:amount 10.0
                           :interval_length 1
                           :interval_unit "day"
                           :name "My new subscription"
                           :user {...}})
(defn new-subscription
  [account {:keys [amount interval_length interval_unit] :as opts}]
  {:pre [(number? amount)
         (number? interval_length)
         (pos? interval_length)
         (contains? #{"day" "week" "month"} interval_unit)]}
  (let [params (assoc opts :amount (bigdec amount))]
    (c/new-limit-url account "subscription" params)))

Returns the Connect URL for a new PreAuthorization.

Required keys: :max_amount, :interval_length, :interval_unit.

Example:

(new-pre-authorization account {:max_amount 10.0
                                :interval_length 4
                                :interval_unit "week"})
(new-pre-authorization account {:max_amount 10.0
                                :interval_length 1
                                :interval_unit "day"
                                :name "My new preauth"
                                :user {...}})
(defn new-pre-authorization
  [account {:keys [max_amount interval_length interval_unit] :as opts}]
  {:pre [(number? max_amount)
         (number? interval_length)
         (pos? interval_length)
         (contains? #{"day" "week" "month"} interval_unit)]}
  (let [params (assoc opts :max_amount (bigdec max_amount))]
    (c/new-limit-url account "pre_authorization" params)))

Authenticating merchant's details

Get the account details.

Example:

(details account)
(defn details
  [account]
  (c/api-get account (c/path "merchants" (:merchant-id account)) {}))

Resource confirmation

Confirm a created limit (bill/subscription/preauthorization).

Signature will be checked. If signatures don't match, the resource will not be confirmed and false is returned.

params is assumed to be a map containing keys and values from the query string. Map keys are assumed to be strings.

Example:

(let [params (:query-params request)]
  (confirm-resource account params))
(defn confirm-resource
  [account params]
  {:pre [(every? (set (keys params))
                 ["resource_id" "resource_type" "resource_uri" "signature"])]}
  (let [ks [:resource_id :resource_type :resource_uri :state :signature]
        params (clojure.walk/keywordize-keys params)
        params (select-keys params ks)
        to-sign (dissoc params :signature)
        data (select-keys params [:resource_id :resource_type])]
    (if (= (:signature params) (sign-params to-sign (:app-secret account)))
      (c/api-post account "confirm" data {:basic-auth [(:app-id account)
                                                       (:app-secret account)]})
      false)))
 
(ns gocardless-clj.protocols)

Flatten params according to the signature guide.

gocardless-clj.signature namespace contains the extension of this protocol for different data types/structures.

(defprotocol PFlattenable
  (flatten-params [coll ns]))
(defprotocol PCancellable
  (cancelable? [resource] "Check if the resource can be cancelled.")
  (cancel [resource account] "Cancel a resource."))
(defprotocol PRetriable
  (retriable? [resource] "Check if the resource can be retried.")
  (retry [resource account] "Retry a resource."))
 
(ns gocardless-clj.resources
  (:require [gocardless-clj.protocols :refer :all]
            [gocardless-clj.client :as client]))
(defrecord Bill [id]
  gocardless-clj.protocols/PCancellable
  (cancelable? [resource]
    (true? (:can_be_cancelled resource)))
  (cancel [resource account]
    (when (cancelable? resource)
      (do
        (client/api-put account (client/path (:uri resource) "cancel") {})
        true)))
  gocardless-clj.protocols/PRetriable
  (retriable? [resource]
    (true? (:can_be_retried resource)))
  (retry [resource account]
    (when (retriable? resource)
      (do
        (client/api-post account (client/path (:uri resource) "retry") {})
        true))))
(defrecord Subscription [id]
  gocardless-clj.protocols/PCancellable
  (cancelable? [resource]
    (not (= "cancelled" (:status resource))))
  (cancel [resource account]
    (when (cancelable? resource)
      (do
        (client/api-put account (client/path (:uri resource) "cancel") {})
        true))))
(defrecord PreAuthorization [id]
  gocardless-clj.protocols/PCancellable
  (cancelable? [resource]
    (not (= "cancelled" (:status resource))))
  (cancel [resource account]
    (when (cancelable? resource)
      (do
        (client/api-put account (client/path (:uri resource) "cancel") {})
        true))))
 

Signature calculation functions

Implements signature calculation according to the signature guide.

(ns gocardless-clj.signature
  (:require [pandect.core :refer [sha256-hmac]]
            [gocardless-clj.protocols :refer [flatten-params]])
  (:import java.net.URLEncoder))
(defn new-ns
  ([ns] (str ns "[]"))
  ([ns k]
     (if ns
       (str ns "[" k "]")
       k)))
(extend-protocol gocardless-clj.protocols/PFlattenable
  clojure.lang.PersistentArrayMap
  (flatten-params [coll ns]
    (let [pairs (map #(flatten-params %2 (new-ns ns %1)) (keys coll) (vals coll))]
      (if (empty? pairs)
        []
        (apply concat pairs))))

  clojure.lang.PersistentHashMap
  (flatten-params [coll ns]
    (let [pairs (map #(flatten-params %2 (new-ns ns %1)) (keys coll) (vals coll))]
      (if (empty? pairs)
        []
        (apply concat pairs))))

  clojure.lang.PersistentVector
  (flatten-params [coll ns]
    (let [pairs (map #(flatten-params %1 (new-ns ns)) coll)]
      (if (empty? pairs)
        []
        (apply concat pairs))))

  java.lang.String
  (flatten-params [string ns]
    [[ns string]])

  clojure.lang.Keyword
  (flatten-params [kw ns]
    [[ns (name kw)]])

  java.lang.Long
  (flatten-params [number ns]
    [[ns (str number)]])

  java.math.BigDecimal
  (flatten-params [number ns]
    [[ns (str number)]]))

Percent-encode a string.

(defn percent-encode
  [s]
  (URLEncoder/encode (name s) "UTF-8"))

Normalises a key-value pair.

Percent-encodes both key and value and joins them with =.

(defn normalise-keyval
  [keyval-pair]
  (clojure.string/join "=" (map percent-encode keyval-pair)))

Genrates a percent-encoded query string.

Individual keys and values are joined with =, joined key-value pairs are joined with &.

(defn normalise-params
  [params]
  (let [flattened-sorted (-> params (flatten-params nil) sort)]
    (clojure.string/join "&" (map normalise-keyval flattened-sorted))))

Sign the normalised params with SHA256 using a key. The key in this case is the app secret.

(defn sign-params
  [params key]
  (-> params normalise-params (sha256-hmac key)))