Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ AllCops:
Style/Documentation:
Enabled: false

Metrics/ParameterLists:
CountKeywordArgs: false

Metrics/MethodLength:
Max: 20

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file.

### Added

- Customer portal support: `Invoices#list_v2` / `#get_v2` (cursor-paginated `CursorCollection`),
`Customers#get_v2`, `#payment_methods`, `#delete_payment_method`, `#portal`,
`Subscriptions#preview_timeline` / `#simulate_updates`, the `Integrations` resource
(`#create_component_token`) and `Hyperline::Webhook.verify!` with `WebhookSignatureError`
- `Invoices#download` follows one redirect without forwarding the Authorization header and returns
a `Hyperline::Download` (`body`, `content_type`, `filename`; `to_str` yields the bytes, so code
that treated the old return value as a String keeps working). It now uses `/v2/invoices/{id}/download`
and accepts `lang:` / `locale:`
- `idempotency_key:` on `Invoices#charge`
- `Collection` accepts `args:` so a nested list (payment methods) pages with its parent id
- Features resource (list, get, create, update, archive, delete), keyed by the feature's `code`
rather than an opaque id
- `Products#features`, `#link_feature`, `#unlink_feature` and `#archive`
Expand Down
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,13 @@ client.invoices.delete("inv_abc")

# Actions
client.invoices.validate("inv_abc") # draft → to_pay
client.invoices.charge("inv_abc") # charge invoice
client.invoices.charge("inv_abc", payment_method_id: "pm_xxx", idempotency_key: "key")
client.invoices.void("inv_abc") # void invoice

# Download PDF
pdf_data = client.invoices.download("inv_abc")
File.binwrite("invoice.pdf", pdf_data)
# Download (follows one redirect; returns Hyperline::Download with body, content_type, filename)
pdf = client.invoices.download("inv_abc", lang: "en", locale: "en-GB")
File.binwrite(pdf.filename || "invoice.pdf", pdf.body)

# Credit notes
client.invoices.create_credit_note("inv_abc", reason: "Duplicate charge")
Expand All @@ -127,6 +128,40 @@ client.invoices.create_transaction("inv_abc", amount: 5000)
client.invoices.delete_transaction("inv_abc", "tx_xxx")
```

### v2 invoices (cursor pagination)

```ruby
page = client.invoices.list_v2(customer_id: "cus_xxx", limit: 50)
page.next_cursor
page.next_page # nil on the last page
client.invoices.list_v2(customer_id: "cus_xxx").auto_paginate { |invoice| process(invoice) }
client.invoices.get_v2("inv_abc")
```

### Customer portal

```ruby
client.customers.get_v2("cus_xxx")
client.customers.payment_methods("cus_xxx", take: 20) # Collection
client.customers.delete_payment_method("cus_xxx", "pm_xxx", idempotency_key: "key")
client.customers.portal("cus_xxx") # => { "url" => ... }
client.subscriptions.preview_timeline("sub_xxx", max_events: 10)
client.subscriptions.simulate_updates("sub_xxx", type: "update_count", payload: { ... })
client.integrations.create_component_token(customer_id: "cus_xxx")
```

### Webhooks

```ruby
event = Hyperline::Webhook.verify!(
payload: request.raw_post, # the raw body, not a re-serialised one
headers: request.headers,
secret: ENV.fetch("HYPERLINE_WEBHOOK_SECRET"),
tolerance: 300
)
# raises Hyperline::WebhookSignatureError when the signature or timestamp is invalid
```

### Pagination

```ruby
Expand Down
4 changes: 4 additions & 0 deletions lib/hyperline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
require_relative 'hyperline/errors'
require_relative 'hyperline/connection'
require_relative 'hyperline/collection'
require_relative 'hyperline/cursor_collection'
require_relative 'hyperline/download'
require_relative 'hyperline/webhook'
require_relative 'hyperline/resources/requests'
require_relative 'hyperline/resources/base_resource'
require_relative 'hyperline/resources/products'
Expand All @@ -17,6 +20,7 @@
require_relative 'hyperline/resources/price_configurations'
require_relative 'hyperline/resources/aggregators'
require_relative 'hyperline/resources/price_books'
require_relative 'hyperline/resources/integrations'
require_relative 'hyperline/client'

module Hyperline
Expand Down
4 changes: 4 additions & 0 deletions lib/hyperline/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,9 @@ def aggregators
def price_books
@price_books ||= Resources::PriceBooks.new(@connection)
end

def integrations
@integrations ||= Resources::Integrations.new(@connection)
end
end
end
8 changes: 5 additions & 3 deletions lib/hyperline/collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ class Collection

# `method` is the resource method that produced this page. It matters because next_page has to
# re-issue the *same* call: defaulting to #list meant a page from a custom list method, such as
# Subscriptions#list_templates, paged into the resource's default endpoint instead.
def initialize(data:, meta:, resource:, params:, method: :list)
# Subscriptions#list_templates, paged into the resource's default endpoint instead. `args` are
# the positional arguments of that method (a nested list needs its parent id).
def initialize(data:, meta:, resource:, params:, method: :list, args: [])
@data = data
@meta = meta
@resource = resource
@params = params
@method = method
@args = args
end

def each(&block)
Expand All @@ -40,7 +42,7 @@ def next_page?
def next_page
return nil unless next_page?

@resource.public_send(@method, **@params, skip: skipped + taken)
@resource.public_send(@method, *@args, **@params, skip: skipped + taken)
end

def each_page
Expand Down
58 changes: 58 additions & 0 deletions lib/hyperline/cursor_collection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

module Hyperline
# A page of a cursor-paginated (v2) list. Mirrors Collection, but pages by an opaque cursor
# instead of a skip offset.
class CursorCollection
include Enumerable

attr_reader :data, :next_cursor

def initialize(data:, next_cursor:, has_more:, resource:, params:, method:, args: [])
@data = data
@next_cursor = next_cursor
@has_more = has_more
@resource = resource
@params = params
@method = method
@args = args
end

def each(&block)
data.each(&block)
end

# A cursor with no `has_more` flag still means another page; a flag without a cursor cannot be
# followed, so both must agree before paging on.
def next_page?
return false if next_cursor.nil? || next_cursor.to_s.empty?

@has_more.nil? || @has_more
end
alias has_more? next_page?

def next_page
return nil unless next_page?

@resource.public_send(@method, *@args, **@params, cursor: next_cursor)
end

def each_page
return enum_for(:each_page) unless block_given?

page = self
loop do
yield page
break unless page.next_page?

page = page.next_page
end
end

def auto_paginate(&block)
return enum_for(:auto_paginate) unless block_given?

each_page { |page| page.each(&block) }
end
end
end
12 changes: 12 additions & 0 deletions lib/hyperline/download.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

module Hyperline
# A downloaded file. `to_str` keeps callers that treated the old return value as the raw bytes
# (File.binwrite, String#include?) working.
Download = Struct.new(:body, :content_type, :filename, keyword_init: true) do
def to_str
body.to_s
end
alias_method :to_s, :to_str
end
end
6 changes: 6 additions & 0 deletions lib/hyperline/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ class RateLimitError < ApiError
class ServerError < ApiError
end

# Raised by Webhook.verify! for any payload that cannot be proven to come from Hyperline:
# missing headers, a stale timestamp, or no matching signature. Not an ApiError because no HTTP
# response is involved.
class WebhookSignatureError < StandardError
end

# @api private
module ErrorMapper
MAPPED = {
Expand Down
16 changes: 16 additions & 0 deletions lib/hyperline/resources/base_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ def next_offset(meta, page_size, skipped)
offset < total.to_i ? offset : nil
end

# Builds a CursorCollection from a v2 envelope. Hyperline's docs describe the cursor at the
# top level (`next_cursor`, `has_more`); a `meta` object is accepted too in case the
# envelope nests them.
def cursor_collection(response, method:, params:, args: [])
meta = response['meta'].is_a?(Hash) ? response['meta'] : {}
CursorCollection.new(
data: Array(response['data']),
next_cursor: response.fetch('next_cursor') { meta['next_cursor'] },
has_more: response.fetch('has_more') { meta['has_more'] },
resource: self,
params: params,
method: method,
args: args
)
end

def base_path
raise NotImplementedError, "#{self.class} must implement #base_path"
end
Expand Down
32 changes: 32 additions & 0 deletions lib/hyperline/resources/customers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,40 @@ def features(id)
request(:get, "#{base_path}/#{id}/features")
end

def get_v2(id)
request(:get, "/v2/customers/#{id}")
end

def payment_methods(id, **params)
response = request(:get, "#{resource_path(id)}/payment-methods", params)
data = response.is_a?(Array) ? response : Array(response['data'])
meta = response.is_a?(Hash) && response['meta'] ? response['meta'] : bare_meta(data)
Collection.new(
data: data, meta: meta, resource: self, params: params,
method: :payment_methods, args: [id]
)
end

def delete_payment_method(id, payment_method_id, idempotency_key: nil)
request(
:delete,
"#{resource_path(id)}/payment-methods/#{payment_method_id}",
nil,
idempotency_key: idempotency_key
)
end

# Returns `{ "url" => ... }`, a link to the customer's hosted portal.
def portal(id)
request(:get, "#{resource_path(id)}/portal")
end

private

def bare_meta(data)
{ 'total' => data.size, 'taken' => data.size, 'skipped' => 0 }
end

def base_path
'/v1/customers'
end
Expand Down
24 changes: 24 additions & 0 deletions lib/hyperline/resources/integrations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Hyperline
module Resources
class Integrations < BaseResource
# Returns `{ "token" => ..., "token_type" => ... }`, a short-lived token for embedding
# Hyperline's integration components for one customer.
def create_component_token(customer_id:, idempotency_key: nil)
request(
:post,
'/v1/integrations/components/token',
{ customer_id: customer_id },
idempotency_key: idempotency_key
)
end

private

def base_path
'/v1/integrations'
end
end
end
end
50 changes: 45 additions & 5 deletions lib/hyperline/resources/invoices.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'uri'

module Hyperline
module Resources
class Invoices < BaseResource
Expand All @@ -11,17 +13,31 @@ def validate(id)
request(:post, "#{resource_path(id)}/validate")
end

def charge(id, **params)
request(:post, "#{resource_path(id)}/charge", params)
def charge(id, idempotency_key: nil, **params)
request(:post, "#{resource_path(id)}/charge", params, idempotency_key: idempotency_key)
end

# GET /v2/invoices. Unlike /v1/invoices, which silently ignores `customer_id`, the v2 list
# honours it. Cursor-paginated: params are `limit` and `cursor`.
def list_v2(**params)
response = request(:get, '/v2/invoices', params)
cursor_collection(response, method: :list_v2, params: params)
end

def get_v2(id)
request(:get, "/v2/invoices/#{id}")
end

def void(id)
request(:post, "#{resource_path(id)}/void")
end

def download(id)
response = connection.get("#{resource_path(id)}/download")
response.body
# The API answers with the PDF itself or a 302 to a temporary URL. The redirect is followed
# once on a bare connection, because the Authorization header must not reach the other host.
def download(id, **params)
response = connection.get("/v2/invoices/#{id}/download", params)
response = follow_redirect(response) if redirect?(response)
build_download(response)
rescue Faraday::ClientError, Faraday::ServerError => e
handle_error(e)
end
Expand All @@ -44,6 +60,30 @@ def delete_transaction(id, transaction_id)

private

def redirect?(response)
[301, 302, 303, 307, 308].include?(response.status) && response.headers['Location']
end

def follow_redirect(response)
target = URI.join(response.env.url.to_s, response.headers['Location'])
bare = Faraday.new do |f|
f.response :raise_error
f.options.timeout = connection.options.timeout
f.options.open_timeout = connection.options.open_timeout
f.adapter Faraday.default_adapter
end
bare.get(target.to_s)
end

def build_download(response)
disposition = response.headers['Content-Disposition'].to_s
Download.new(
body: response.body,
content_type: response.headers['Content-Type'],
filename: disposition[/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i, 1]
)
end

def base_path
'/v1/invoices'
end
Expand Down
Loading