paddle 2.10 → 3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/paddle/client.rb CHANGED
@@ -2,43 +2,60 @@ require "faraday"
2
2
 
3
3
  module Paddle
4
4
  class Client
5
+ @connections = {}
6
+ @mutex = Mutex.new
7
+
5
8
  class << self
9
+ # One connection is kept per base URL and set of connection options. The API key and
10
+ # version are sent with each request, so config changes take effect straight away
6
11
  def connection
7
- @connection ||= create_connection
12
+ config = Paddle.config
13
+ key = [ config.url, config.connection_options.hash ]
14
+
15
+ @mutex.synchronize do
16
+ @connections[key] ||= create_connection(config)
17
+ end
8
18
  end
9
19
 
10
20
  def get_request(url, params: {}, headers: {})
11
- handle_response(connection.get(url, params, headers))
21
+ # Paddle expects arrays as comma-separated lists, e.g. status=active,past_due
22
+ params = params.transform_values { |value| value.is_a?(Array) ? value.join(",") : value }
23
+
24
+ # skip_count is sent as a header rather than a query param
25
+ headers = headers.merge("Skip-Count" => "true") if params.delete(:skip_count)
26
+
27
+ handle_response(connection.get(url, params, request_headers(headers)))
12
28
  end
13
29
 
14
30
  def post_request(url, body: {}, headers: {})
15
- handle_response(connection.post(url, body, headers))
31
+ handle_response(connection.post(url, body, request_headers(headers)))
16
32
  end
17
33
 
18
34
  def patch_request(url, body:, headers: {})
19
- handle_response(connection.patch(url, body, headers))
35
+ handle_response(connection.patch(url, body, request_headers(headers)))
20
36
  end
21
37
 
22
38
  def delete_request(url, headers: {})
23
- handle_response(connection.delete(url, headers))
39
+ handle_response(connection.delete(url, nil, request_headers(headers)))
24
40
  end
25
41
 
26
42
  private
27
43
 
28
- def create_connection
29
- Faraday.new(Paddle.config.url, Paddle.config.connection_options) do |conn|
30
- conn.request :authorization, :Bearer, Paddle.config.api_key
31
- conn.headers = default_headers
44
+ def create_connection(config)
45
+ Faraday.new(config.url, config.connection_options) do |conn|
46
+ conn.headers = { "User-Agent" => "paddle/v#{VERSION} (github.com/d34ndev/paddle)" }
32
47
  conn.request :json
33
48
  conn.response :json
34
49
  end
35
50
  end
36
51
 
37
- def default_headers
52
+ def request_headers(headers)
53
+ config = Paddle.config
54
+
38
55
  {
39
- "User-Agent" => "paddle/v#{VERSION} (github.com/deanpcmad/paddle)",
40
- "Paddle-Version" => Paddle.config.version.to_s
41
- }
56
+ "Authorization" => "Bearer #{config.api_key}",
57
+ "Paddle-Version" => config.version.to_s
58
+ }.merge(headers)
42
59
  end
43
60
 
44
61
  def handle_response(response)
@@ -49,8 +66,7 @@ module Paddle
49
66
  end
50
67
 
51
68
  def error?(response)
52
- [ 400, 401, 403, 404, 409, 429, 500, 501, 503 ].include?(response.status) ||
53
- response.body&.key?("error")
69
+ !response.success? || (response.body.is_a?(Hash) && response.body.key?("error"))
54
70
  end
55
71
 
56
72
  def raise_error(response)
@@ -2,28 +2,64 @@ module Paddle
2
2
  class Collection
3
3
  include Enumerable
4
4
 
5
- attr_reader :data, :total
5
+ attr_reader :data, :total, :per_page, :next_url
6
6
 
7
7
  def self.from_response(response, type:)
8
8
  body = response.body
9
9
 
10
- data = body["data"].map { |attrs| type.new(attrs) }
10
+ data = body["data"].map { |attrs| type.new(attrs) }
11
+ pagination = body.dig("meta", "pagination")
11
12
 
12
- if body["meta"]["pagination"]
13
- total = body["meta"]["pagination"]["estimated_total"]
13
+ if pagination
14
+ new(
15
+ data: data,
16
+ total: pagination["estimated_total"],
17
+ per_page: pagination["per_page"],
18
+ has_more: pagination["has_more"],
19
+ next_url: pagination["next"],
20
+ type: type,
21
+ skip_count: response.env.request_headers["Skip-Count"] == "true"
22
+ )
14
23
  else
15
- total = body["data"].count
24
+ new(data: data, total: data.count)
16
25
  end
17
-
18
- new(
19
- data: data,
20
- total: total
21
- )
22
26
  end
23
27
 
24
- def initialize(data:, total:)
28
+ def initialize(data:, total:, per_page: nil, has_more: false, next_url: nil, type: nil, skip_count: false)
25
29
  @data = data
26
30
  @total = total
31
+ @per_page = per_page
32
+ @has_more = has_more
33
+ @next_url = next_url
34
+ @type = type
35
+ @skip_count = skip_count
36
+ end
37
+
38
+ def has_more?
39
+ @has_more == true
40
+ end
41
+
42
+ def skip_count?
43
+ @skip_count
44
+ end
45
+
46
+ # Paddle returns a next URL even on the last page, so has_more is checked first
47
+ def next_page
48
+ return unless has_more? && next_url && @type
49
+
50
+ # Only the path and query are used, so requests always go to the configured API host
51
+ response = Client.get_request(URI(next_url).request_uri.delete_prefix("/"), params: { skip_count: skip_count? })
52
+ Collection.from_response(response, type: @type)
53
+ end
54
+
55
+ def auto_paging_each(&block)
56
+ return enum_for(:auto_paging_each) unless block_given?
57
+
58
+ page = self
59
+ while page
60
+ page.each(&block)
61
+ page = page.next_page
62
+ end
27
63
  end
28
64
 
29
65
  def each(&block)
@@ -3,23 +3,67 @@
3
3
  module Paddle
4
4
  class Configuration
5
5
  attr_reader :environment
6
+ attr_reader :api_key
6
7
 
7
8
  attr_accessor :version
8
- attr_accessor :api_key
9
9
  attr_accessor :connection_options
10
10
 
11
11
  def initialize
12
- @environment ||= :production
12
+ @environment = :production
13
+ @environment_set = false
13
14
  @version ||= 1
14
15
  @connection_options = {}
15
16
  end
16
17
 
18
+ # When the environment isn't set, it's detected from the API key
17
19
  def environment=(env)
18
- env = env.nil? ? :production : env.to_sym
20
+ if env.nil?
21
+ @environment_set = false
22
+ @environment = key_environment || :production
23
+ return
24
+ end
25
+
26
+ env = env.to_sym
19
27
  unless [ :development, :sandbox, :production ].include?(env)
20
28
  raise ArgumentError, "#{env.inspect} is not a valid environment"
21
29
  end
30
+
31
+ check_key_matches!(env)
22
32
  @environment = env
33
+ @environment_set = true
34
+ end
35
+
36
+ def api_key=(key)
37
+ @api_key = key
38
+
39
+ if @environment_set
40
+ check_key_matches!(@environment)
41
+ else
42
+ @environment = key_environment || :production
43
+ end
44
+ end
45
+
46
+ MERGEABLE = [ :api_key, :environment, :version, :connection_options ].freeze
47
+
48
+ # Returns a new Configuration with the given options applied over this one. When a new
49
+ # API key is given without an environment, the environment is detected from the key,
50
+ # or kept from this config for older keys without a prefix
51
+ def merge(**options)
52
+ unknown = options.keys - MERGEABLE
53
+ raise ArgumentError, "Unknown config options: #{unknown.join(", ")}" if unknown.any?
54
+
55
+ config = Configuration.new
56
+ config.version = options.fetch(:version, version)
57
+ config.connection_options = options.fetch(:connection_options, connection_options)
58
+ config.api_key = options.fetch(:api_key, api_key)
59
+
60
+ if options.key?(:environment)
61
+ config.environment = options[:environment]
62
+ elsif !options.key?(:api_key) || config.key_environment.nil?
63
+ config.environment = environment
64
+ end
65
+
66
+ config
23
67
  end
24
68
 
25
69
  def url
@@ -30,5 +74,24 @@ module Paddle
30
74
  "https://sandbox-api.paddle.com"
31
75
  end
32
76
  end
77
+
78
+ protected
79
+
80
+ # API keys created since May 2025 start with pdl_live_ or pdl_sdbx_. Older keys have no prefix
81
+ def key_environment
82
+ case @api_key
83
+ when /\Apdl_live_/ then :production
84
+ when /\Apdl_sdbx_/ then :sandbox
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def check_key_matches!(env)
91
+ key_env = key_environment
92
+ return if key_env.nil? || (key_env == :production) == (env == :production)
93
+
94
+ raise ArgumentError, "The API key is for the #{key_env} environment, but environment is set to #{env.inspect}"
95
+ end
33
96
  end
34
97
  end
@@ -8,7 +8,8 @@ module Paddle
8
8
  attr_reader :request_id
9
9
 
10
10
  def initialize(response_body, http_status_code)
11
- @response_body = response_body
11
+ # Non-JSON responses (e.g. an HTML page from a gateway) have no Paddle error details
12
+ @response_body = response_body.is_a?(Hash) ? response_body : {}
12
13
  @http_status_code = http_status_code
13
14
  set_paddle_error_values
14
15
  super(build_message)
@@ -25,9 +26,7 @@ module Paddle
25
26
  end
26
27
 
27
28
  def error_message
28
- @paddle_error_message || @response_body.dig("error", "code")
29
- rescue NoMethodError
30
- "An unknown error occurred."
29
+ @paddle_error_message || @paddle_error_code || "An unknown error occurred."
31
30
  end
32
31
 
33
32
  def build_message
@@ -121,7 +120,7 @@ module Paddle
121
120
  private
122
121
 
123
122
  def error_message
124
- "You have been rate limited for sending more than 20 requests per second."
123
+ "The Paddle API is temporarily unavailable. Try again later."
125
124
  end
126
125
  end
127
126
 
@@ -6,17 +6,17 @@ module Paddle
6
6
  Collection.from_response(response, type: Adjustment)
7
7
  end
8
8
 
9
- def create(transaction_id:, action:, reason:, items:, **params)
10
- attrs = { transaction_id: transaction_id, action: action, reason: reason, items: items }
9
+ # items can be omitted when type is "full"
10
+ def create(transaction_id:, action:, reason:, items: nil, **params)
11
+ attrs = { transaction_id: transaction_id, action: action, reason: reason }
12
+ attrs[:items] = items if items
11
13
  response = Client.post_request("adjustments", body: attrs.merge(params))
12
14
  Adjustment.new(response.body["data"])
13
15
  end
14
16
 
15
17
  def credit_note(id:, disposition: "attachment")
16
- response = Client.get_request("adjustments/#{id}/credit-note?disposition=#{disposition}")
17
- if response.success?
18
- response.body["data"]["url"]
19
- end
18
+ response = Client.get_request("adjustments/#{id}/credit-note", params: { disposition: disposition })
19
+ response.body["data"]["url"]
20
20
  end
21
21
  end
22
22
  end
@@ -22,9 +22,15 @@ module Paddle
22
22
  Customer.new(response.body["data"])
23
23
  end
24
24
 
25
+ def credit_balances(id:, **params)
26
+ response = Client.get_request("customers/#{id}/credit-balances", params: params)
27
+ Collection.from_response(response, type: CreditBalance)
28
+ end
29
+
30
+ # Returns only the first credit balance. Customers have a balance per currency,
31
+ # so use credit_balances to get all of them.
25
32
  def credit(id:)
26
- response = Client.get_request("customers/#{id}/credit-balances")
27
- CreditBalance.new(response.body["data"][0])
33
+ credit_balances(id: id).first
28
34
  end
29
35
 
30
36
  def auth_token(id:)
@@ -0,0 +1,4 @@
1
+ module Paddle
2
+ class ExploreEntity < Object
3
+ end
4
+ end
@@ -0,0 +1,50 @@
1
+ module Paddle
2
+ # The result of an Explore query. Pages are made up of series (one per combination of dimension
3
+ # values), and the next page is requested by POSTing the same query to the next URL
4
+ class ExploreResult < Object
5
+ def self.from_response(response, query:)
6
+ new(response.body["data"], query: query, pagination: response.body.dig("meta", "pagination"))
7
+ end
8
+
9
+ def initialize(attributes, query: {}, pagination: nil)
10
+ super(attributes)
11
+ @query = query
12
+ @pagination = pagination || {}
13
+ end
14
+
15
+ def per_page
16
+ @pagination["per_page"]
17
+ end
18
+
19
+ def total
20
+ @pagination["estimated_total"]
21
+ end
22
+
23
+ def next_url
24
+ @pagination["next"]
25
+ end
26
+
27
+ def has_more?
28
+ @pagination["has_more"] == true
29
+ end
30
+
31
+ def next_page
32
+ return unless has_more? && next_url
33
+
34
+ # Only the path and query are used, so requests always go to the configured API host
35
+ response = Client.post_request(URI(next_url).request_uri.delete_prefix("/"), body: @query)
36
+ ExploreResult.from_response(response, query: @query)
37
+ end
38
+
39
+ # Yields each series across all pages
40
+ def auto_paging_each(&block)
41
+ return enum_for(:auto_paging_each) unless block_given?
42
+
43
+ page = self
44
+ while page
45
+ page.series.each(&block)
46
+ page = page.next_page
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,55 @@
1
+ module Paddle
2
+ class Metric < Object
3
+ class << self
4
+ # from and to are dates, e.g. "2025-09-01" or a Date. Returns daily data with a timeseries
5
+ def monthly_recurring_revenue(from:, to:)
6
+ get("monthly-recurring-revenue", from: from, to: to)
7
+ end
8
+
9
+ def monthly_recurring_revenue_change(from:, to:)
10
+ get("monthly-recurring-revenue-change", from: from, to: to)
11
+ end
12
+
13
+ def active_subscribers(from:, to:)
14
+ get("active-subscribers", from: from, to: to)
15
+ end
16
+
17
+ def revenue(from:, to:)
18
+ get("revenue", from: from, to: to)
19
+ end
20
+
21
+ def refunds(from:, to:)
22
+ get("refunds", from: from, to: to)
23
+ end
24
+
25
+ def chargebacks(from:, to:)
26
+ get("chargebacks", from: from, to: to)
27
+ end
28
+
29
+ def checkout_conversion(from:, to:)
30
+ get("checkout-conversion", from: from, to: to)
31
+ end
32
+
33
+ # Lists the entities that can be queried with explore, with their dimensions and measures
34
+ def explore_entities(**params)
35
+ response = Client.get_request("metrics/explore/entities", params: params)
36
+ Collection.from_response(response, type: ExploreEntity)
37
+ end
38
+
39
+ # Runs an Explore query. from is inclusive and to is exclusive.
40
+ # measures is an array of hashes, e.g. [ { field: "gross_revenue", agg: "sum" } ]
41
+ def explore(entity:, from:, to:, measures:, **params)
42
+ query = { entity: entity, from: from, to: to, measures: measures }.merge(params)
43
+ response = Client.post_request("metrics/explore", body: query)
44
+ ExploreResult.from_response(response, query: query)
45
+ end
46
+
47
+ private
48
+
49
+ def get(metric, from:, to:)
50
+ response = Client.get_request("metrics/#{metric}", params: { from: from, to: to })
51
+ Metric.new(response.body["data"])
52
+ end
53
+ end
54
+ end
55
+ end
@@ -11,11 +11,12 @@ module Paddle
11
11
  Notification.new(response.body["data"])
12
12
  end
13
13
 
14
- # Currently not working
15
- # def replay(id)
16
- # response = Client.post_request("notifications/#{id}/replay", body: {})
17
- # Notification.new(response.body["data"])
18
- # end
14
+ # Only delivered or failed notifications with an origin of "event" can be replayed.
15
+ # Returns a Notification with the notification_id of the new notification.
16
+ def replay(id:)
17
+ response = Client.post_request("notifications/#{id}/replay")
18
+ Notification.new(response.body["data"])
19
+ end
19
20
 
20
21
  def logs(id:, **params)
21
22
  response = Client.get_request("notifications/#{id}/logs", params: params)
@@ -19,9 +19,7 @@ module Paddle
19
19
 
20
20
  def csv(id:)
21
21
  response = Client.get_request("reports/#{id}/download-url")
22
- if response.success?
23
- response.body["data"]["url"]
24
- end
22
+ response.body["data"]["url"]
25
23
  end
26
24
  end
27
25
  end
@@ -22,8 +22,8 @@ module Paddle
22
22
  Simulation.new(response.body["data"])
23
23
  end
24
24
 
25
- def runs(id:)
26
- response = Client.get_request("simulations/#{id}/runs")
25
+ def runs(id:, **params)
26
+ response = Client.get_request("simulations/#{id}/runs", params: params)
27
27
  Collection.from_response(response, type: SimulationRun)
28
28
  end
29
29
  end
@@ -11,8 +11,8 @@ module Paddle
11
11
  SimulationRun.new(response.body["data"])
12
12
  end
13
13
 
14
- def events(simulation_id:, id:)
15
- response = Client.get_request("simulations/#{simulation_id}/runs/#{id}/events")
14
+ def events(simulation_id:, id:, **params)
15
+ response = Client.get_request("simulations/#{simulation_id}/runs/#{id}/events", params: params)
16
16
  Collection.from_response(response, type: SimulationRunEvent)
17
17
  end
18
18
  end
@@ -12,6 +12,11 @@ module Paddle
12
12
  Subscription.new(response.body["data"])
13
13
  end
14
14
 
15
+ def history(id:, **params)
16
+ response = Client.get_request("subscriptions/#{id}/history", params: params)
17
+ Collection.from_response(response, type: SubscriptionHistory)
18
+ end
19
+
15
20
  def get_transaction(id:)
16
21
  response = Client.get_request("subscriptions/#{id}/update-payment-method-transaction")
17
22
  Transaction.new(response.body["data"])
@@ -33,6 +38,13 @@ module Paddle
33
38
  Subscription.new(response.body["data"])
34
39
  end
35
40
 
41
+ # Previews a one-time charge without billing it. Takes the same params as charge
42
+ def charge_preview(id:, items:, effective_from:, **params)
43
+ attrs = { items: items, effective_from: effective_from }
44
+ response = Client.post_request("subscriptions/#{id}/charge/preview", body: attrs.merge(params))
45
+ Subscription.new(response.body["data"])
46
+ end
47
+
36
48
  def pause(id:, **params)
37
49
  response = Client.post_request("subscriptions/#{id}/pause", body: params)
38
50
  Subscription.new(response.body["data"])
@@ -0,0 +1,4 @@
1
+ module Paddle
2
+ class SubscriptionHistory < Object
3
+ end
4
+ end
@@ -23,11 +23,16 @@ module Paddle
23
23
  Transaction.new(response.body["data"])
24
24
  end
25
25
 
26
+ # Revises customer, business and address details on a billed or completed transaction.
27
+ # A transaction can only be revised once
28
+ def revise(id:, **params)
29
+ response = Client.post_request("transactions/#{id}/revise", body: params)
30
+ Transaction.new(response.body["data"])
31
+ end
32
+
26
33
  def invoice(id:, disposition: "attachment")
27
- response = Client.get_request("transactions/#{id}/invoice?disposition=#{disposition}")
28
- if response.success?
29
- response.body["data"]["url"]
30
- end
34
+ response = Client.get_request("transactions/#{id}/invoice", params: { disposition: disposition })
35
+ response.body["data"]["url"]
31
36
  end
32
37
 
33
38
  def preview(items:, **params)
data/lib/paddle/object.rb CHANGED
@@ -1,23 +1,66 @@
1
- require "ostruct"
2
-
3
1
  module Paddle
4
- class Object < OpenStruct
5
- def initialize(attributes)
6
- super to_ostruct(attributes)
7
- end
8
-
9
- def to_ostruct(obj)
10
- if obj.is_a?(Hash)
11
- OpenStruct.new(obj.map { |key, val| [ key, to_ostruct(val) ] }.to_h)
12
- elsif obj.is_a?(Array)
13
- obj.map { |o| to_ostruct(o) }
14
- else # Assumed to be a primitive value
15
- obj
16
- end
2
+ # A lightweight wrapper around API response data. Attributes can be read with dot notation
3
+ # (object.id) or like a hash (object[:id] or object["id"]). Nested hashes are wrapped too.
4
+ class Object
5
+ def initialize(attributes = {})
6
+ @attributes = {}
7
+ (attributes || {}).each { |key, val| self[key] = val }
8
+ end
9
+
10
+ def [](key)
11
+ @attributes[key.to_sym]
12
+ end
13
+
14
+ def []=(key, val)
15
+ @attributes[key.to_sym] = wrap(val)
16
+ end
17
+
18
+ def key?(key)
19
+ @attributes.key?(key.to_sym)
20
+ end
21
+
22
+ def dig(key, *rest)
23
+ val = self[key]
24
+ rest.empty? || val.nil? ? val : val.dig(*rest)
17
25
  end
18
26
 
27
+ def each_pair(&block)
28
+ return enum_for(:each_pair) unless block_given?
29
+
30
+ @attributes.each_pair(&block)
31
+ self
32
+ end
33
+
34
+ # Returns the attributes as a hash, with nested objects converted to hashes too
35
+ def to_h
36
+ @attributes.transform_values { |val| unwrap(val) }
37
+ end
38
+
39
+ def as_json(*)
40
+ to_h
41
+ end
42
+
43
+ def to_json(*args)
44
+ to_h.to_json(*args)
45
+ end
46
+
47
+ def ==(other)
48
+ other.is_a?(Paddle::Object) && to_h == other.to_h
49
+ end
50
+ alias eql? ==
51
+
52
+ def hash
53
+ to_h.hash
54
+ end
55
+
56
+ def inspect
57
+ attrs = @attributes.map { |key, val| "#{key}=#{val.inspect}" }.join(", ")
58
+ "#<#{self.class.name}#{" " unless attrs.empty?}#{attrs}>"
59
+ end
60
+ alias to_s inspect
61
+
19
62
  def update(**params)
20
- method_missing :update unless klass.respond_to? :update
63
+ raise NoMethodError, "undefined method 'update' for #{klass}" unless klass.respond_to? :update
21
64
 
22
65
  primary_attributes = klass.method(:update).parameters.select { |(type, name)| type == :keyreq }.map(&:last) # Identified by whatever is a required named parameter.
23
66
 
@@ -36,5 +79,37 @@ module Paddle
36
79
  def klass
37
80
  self.class
38
81
  end
82
+
83
+ # Unknown attributes return nil, like a hash
84
+ def method_missing(name, *args)
85
+ if name.end_with?("=") && args.size == 1
86
+ self[name.to_s.chomp("=")] = args.first
87
+ elsif args.empty? && !name.end_with?("=", "?", "!")
88
+ self[name]
89
+ else
90
+ super
91
+ end
92
+ end
93
+
94
+ def respond_to_missing?(name, include_private = false)
95
+ key?(name.to_s.chomp("=")) || super
96
+ end
97
+
98
+ def wrap(val)
99
+ case val
100
+ when Paddle::Object then val
101
+ when Hash then Paddle::Object.new(val)
102
+ when Array then val.map { |v| wrap(v) }
103
+ else val
104
+ end
105
+ end
106
+
107
+ def unwrap(val)
108
+ case val
109
+ when Paddle::Object then val.to_h
110
+ when Array then val.map { |v| unwrap(v) }
111
+ else val
112
+ end
113
+ end
39
114
  end
40
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Paddle
4
- VERSION = "2.10"
4
+ VERSION = "3.0"
5
5
  end