polar_sh 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec3d4123f3e156b0f1831d24dd9657d0c12c34597d3123d9d22ca43913881d63
4
- data.tar.gz: c716d766937d240ced1bb1f1fb1fa5b2a1539ff6895ae7db9077d169957d721c
3
+ metadata.gz: 84bce050ddccdcfb7709e2815db0ead0e4591cca1fe007972be02b217c5172da
4
+ data.tar.gz: 30c54873668cabb778e3134b69b1ddf19c12fcbb72d75a65c3f43ad35d938cdc
5
5
  SHA512:
6
- metadata.gz: a4c7ec8ef2136ba7a530579c2f01c97ac7a030d7b31c0c0a1de5a71f4dec1d5887370f34620c9fa6a86476569456d366060e1e3d184ff0f54c2c20423ff1f118
7
- data.tar.gz: 5f7ad2caeeff953d840da4b026ef6ee14e8ddc554760a6c405434c98a961b577da364da8deea9d120471001b5f8c0a27e714aa927c06269d2935aba75ab49b82
6
+ metadata.gz: '078fcb5ab44cd333d9fc3cf935d22c55386c1395626d7d0c181792267be2c6f7515610f5ee7755670ed82d82d79c94b8c9b9f6b06e25e5740af836777fdf06ac'
7
+ data.tar.gz: 7365e3ebcf7953f097af85d56164769da44b36856661c369adf86d2c91eefd91b29ce9eeaa2cacf5794017489f433f426917138cca9c95729baa55e783952413
data/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
- ## [Unreleased]
1
+ ## 0.2.0
2
2
 
3
- ## [0.1.0] - 2024-12-20
3
+ - Add `Polar::Webhook`
4
+ - Add more resources
5
+
6
+ ## 0.1.0
4
7
 
5
8
  - Initial release
data/README.md CHANGED
@@ -14,22 +14,59 @@ bundle add polar_sh
14
14
  Polar.configure do |config|
15
15
  config.access_token = "polar_..."
16
16
  config.sandbox = true
17
+ config.webhook_secret = "xyz..."
17
18
  end
18
19
 
19
- # Fetch a list of customers
20
- pp Polar::Customer.list
20
+ class CheckoutsController < ApplicationController
21
+ def create
22
+ checkout = Polar::Checkout::Custom.create(
23
+ customer_email: current_user.email,
24
+ metadata: {user_id: current_user.id},
25
+ product_id: "xyzxyz-...",
26
+ success_url: root_path
27
+ )
28
+
29
+ redirect_to(checkout.url, allow_other_host: true)
30
+ end
31
+ end
32
+
33
+ class WebhooksController < ApplicationController
34
+ skip_before_action :verify_authenticity_token
35
+
36
+ def handle_polar
37
+ event = Polar::Webhook.verify(request)
38
+
39
+ Rails.logger.info("Received Polar webhook: #{event.type}")
40
+
41
+ case event.type
42
+ when "order.created"
43
+ process_order(event.object)
44
+ end
45
+
46
+ head(:ok)
47
+ end
48
+
49
+ private
50
+
51
+ def process_order(order)
52
+ return unless order.status == "paid"
53
+ return unless (user = User.find(order.metadata[:user_id]))
54
+
55
+ # ...
56
+ end
57
+ end
21
58
  ```
22
59
 
23
60
  ## Development
24
61
 
25
62
  ```sh
26
- $ bundle
27
- $ rake spec
63
+ bundle
64
+ rake spec
28
65
  ```
29
66
 
30
67
  ## Contributing
31
68
 
32
- Bug reports and pull requests are welcome on GitHub at https://github.com/mikker/polar_sh.
69
+ Bug reports and pull requests are welcome on GitHub at <https://github.com/mikker/polar_sh>.
33
70
 
34
71
  ## License
35
72
 
@@ -4,6 +4,7 @@ module Polar
4
4
  class Configuration
5
5
  attr_accessor :access_token
6
6
  attr_accessor :sandbox
7
+ attr_accessor :webhook_secret
7
8
 
8
9
  alias sandbox? sandbox
9
10
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polar
4
+ class Benefit < Resource
5
+ def self.list(params = {})
6
+ response = Client.get_request("/v1/benefits", **params)
7
+ handle_list(response)
8
+ end
9
+
10
+ def self.create(params)
11
+ response = Client.post_request("/v1/benefits", **params)
12
+ handle_one(response)
13
+ end
14
+
15
+ def self.get(id)
16
+ response = Client.get_request("/v1/benefits/#{id}")
17
+ handle_one(response)
18
+ end
19
+
20
+ def self.update(id, params)
21
+ response = Client.patch_request("/v1/benefits/#{id}", **params)
22
+ handle_one(response)
23
+ end
24
+
25
+ def self.delete(id)
26
+ response = Client.delete_request("/v1/benefits/#{id}")
27
+ handle_one(response)
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polar
4
+ class BenefitGrant < Resource
5
+ def self.list(params = {})
6
+ response = Client.get_request("/v1/benefits/grants", **params)
7
+ handle_list(response)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polar
4
+ class Order < Resource
5
+ def self.list(params = {})
6
+ response = Client.get_request("/v1/orders", **params)
7
+ handle_list(response)
8
+ end
9
+
10
+ def self.get(id)
11
+ response = Client.get_request("/v1/orders/#{id}")
12
+ handle_one(response)
13
+ end
14
+
15
+ def self.get_invoice(id)
16
+ response = Client.get_request("/v1/orders/#{id}/invoice")
17
+ handle_one(response, Invoice)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polar
4
+ class Refund < Resource
5
+ def self.list(params = {})
6
+ response = Client.get_request("/v1/refunds", **params)
7
+ handle_list(response)
8
+ end
9
+
10
+ def self.create(params)
11
+ response = Client.post_request("/v1/refunds", **params)
12
+ handle_one(response)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polar
4
+ class Subscription < Resource
5
+ def self.list(params = {})
6
+ response = Client.get_request("/v1/subscriptions", **params)
7
+ handle_list(response)
8
+ end
9
+
10
+ def self.export(params = {})
11
+ response = Client.get_request("/v1/subscriptions/export", **params)
12
+ handle_list(response)
13
+ end
14
+
15
+ def self.get(id)
16
+ response = Client.get_request("/v1/subscriptions/#{id}")
17
+ handle_one(response)
18
+ end
19
+
20
+ def self.update(id, params)
21
+ response = Client.patch_request("/v1/subscriptions/#{id}", **params)
22
+ handle_one(response)
23
+ end
24
+
25
+ def self.delete(id)
26
+ response = Client.delete_request("/v1/subscriptions/#{id}")
27
+ handle_one(response)
28
+ end
29
+
30
+ # Customer Portal methods
31
+ def self.list_for_customer(params = {})
32
+ response = Client.get_request("/v1/customer-portal/subscriptions", **params)
33
+ handle_list(response)
34
+ end
35
+
36
+ def self.get_for_customer(id)
37
+ response = Client.get_request("/v1/customer-portal/subscriptions/#{id}")
38
+ handle_one(response)
39
+ end
40
+
41
+ def self.update_for_customer(id, params)
42
+ response = Client.patch_request("/v1/customer-portal/subscriptions/#{id}", **params)
43
+ handle_one(response)
44
+ end
45
+
46
+ def self.delete_for_customer(id)
47
+ response = Client.delete_request("/v1/customer-portal/subscriptions/#{id}")
48
+ handle_one(response)
49
+ end
50
+ end
51
+ end
52
+
data/lib/polar/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Polar
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,49 @@
1
+ module Polar
2
+ class Webhook
3
+ def initialize(request, secret: nil)
4
+ unless (unencoded_secret = secret || Polar.config.webhook_secret) && unencoded_secret != ""
5
+ raise ArgumentError, "No webhook secret provided, set Polar.config.webhook_secret"
6
+ end
7
+
8
+ @secret = Base64.encode64(unencoded_secret)
9
+ @request = request
10
+ @payload = JSON.parse(request.raw_post, symbolize_names: true)
11
+ @type = @payload[:type]
12
+ @object = cast_object
13
+ end
14
+
15
+ attr_reader :secret, :request, :payload, :type, :object
16
+
17
+ def verify
18
+ StandardWebhooks::Webhook.new(@secret).verify(request.raw_post, request.headers)
19
+ self
20
+ end
21
+
22
+ def cast_object
23
+ case type
24
+ when /^checkout\./
25
+ Checkout::Custom.handle_one(payload[:data])
26
+ when /^order\./
27
+ Order.handle_one(payload[:data])
28
+ when /^subscription\./
29
+ Subscription.handle_one(payload[:data])
30
+ when /^refund\./
31
+ Refund.handle_one(payload[:data])
32
+ when /^product\./
33
+ Product.handle_one(payload[:data])
34
+ when /^pledge\./
35
+ Pledge.handle_one(payload[:data])
36
+ when /^organization\./
37
+ Organization.handle_one(payload[:data])
38
+ when /^benefit\./
39
+ Benefit.handle_one(payload[:data])
40
+ when /^benefit_grant\./
41
+ BenefitGrant.handle_one(payload[:data])
42
+ end
43
+ end
44
+
45
+ def self.verify(request, secret: nil)
46
+ new(request, secret: secret).verify
47
+ end
48
+ end
49
+ end
data/lib/polar.rb CHANGED
@@ -4,20 +4,27 @@ require "ostruct"
4
4
  require "http"
5
5
 
6
6
  require_relative "polar/version"
7
+ require "standard_webhooks"
7
8
 
8
9
  module Polar
9
10
  autoload :Configuration, "polar/configuration"
10
11
  autoload :Client, "polar/client"
11
12
  autoload :Error, "polar/error"
12
13
  autoload :Resource, "polar/resource"
14
+ autoload :Webhook, "polar/webhook"
13
15
 
16
+ autoload :Benefit, "polar/resources/benefit"
17
+ autoload :BenefitGrant, "polar/resources/benefit_grant"
14
18
  autoload :Customer, "polar/resources/customer"
15
19
  autoload :CustomerSession, "polar/resources/customer_session"
16
20
  autoload :Discount, "polar/resources/discount"
17
21
  autoload :Checkout, "polar/resources/checkout"
18
22
  autoload :LicenseKey, "polar/resources/license_key"
23
+ autoload :Order, "polar/resources/order"
19
24
  autoload :Organization, "polar/resources/organization"
20
25
  autoload :Product, "polar/resources/product"
26
+ autoload :Refund, "polar/resources/refund"
27
+ autoload :Subscription, "polar/resources/subscription"
21
28
  autoload :User, "polar/resources/user"
22
29
 
23
30
  class << self
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "base64"
6
+ require "uri"
7
+
8
+ # Constant time string comparison, for fixed length strings.
9
+ # Code borrowed from ActiveSupport
10
+ # https://github.com/rails/rails/blob/75ac626c4e21129d8296d4206a1960563cc3d4aa/activesupport/lib/active_support/security_utils.rb#L33
11
+ #
12
+ # The values compared should be of fixed length, such as strings
13
+ # that have already been processed by HMAC. Raises in case of length mismatch.
14
+ module StandardWebhooks
15
+ if defined?(OpenSSL.fixed_length_secure_compare)
16
+ def fixed_length_secure_compare(a, b)
17
+ OpenSSL.fixed_length_secure_compare(a, b)
18
+ end
19
+ else
20
+ def fixed_length_secure_compare(a, b)
21
+ raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
22
+
23
+ l = a.unpack("C#{a.bytesize}")
24
+
25
+ res = 0
26
+ b.each_byte { |byte| res |= byte ^ l.shift }
27
+ res == 0
28
+ end
29
+ end
30
+
31
+ module_function :fixed_length_secure_compare
32
+
33
+ # Secure string comparison for strings of variable length.
34
+ #
35
+ # While a timing attack would not be able to discern the content of
36
+ # a secret compared via secure_compare, it is possible to determine
37
+ # the secret length. This should be considered when using secure_compare
38
+ # to compare weak, short secrets to user input.
39
+ def secure_compare(a, b)
40
+ a.length == b.length && fixed_length_secure_compare(a, b)
41
+ end
42
+
43
+ module_function :secure_compare
44
+
45
+ class StandardWebhooksError < StandardError
46
+ attr_reader :message
47
+
48
+ def initialize(message = nil)
49
+ @message = message
50
+ end
51
+ end
52
+
53
+ class WebhookVerificationError < StandardWebhooksError
54
+ end
55
+
56
+ class WebhookSigningError < StandardWebhooksError
57
+ end
58
+
59
+ class Webhook
60
+ def self.new_using_raw_bytes(secret)
61
+ self.new(secret.pack("C*").force_encoding("UTF-8"))
62
+ end
63
+
64
+ def initialize(secret)
65
+ if secret.start_with?(SECRET_PREFIX)
66
+ secret = secret[SECRET_PREFIX.length..-1]
67
+ end
68
+
69
+ @secret = Base64.decode64(secret)
70
+ end
71
+
72
+ def verify(payload, headers)
73
+ msg_id = headers["webhook-id"]
74
+ msg_signature = headers["webhook-signature"]
75
+ msg_timestamp = headers["webhook-timestamp"]
76
+
77
+ if !msg_signature || !msg_id || !msg_timestamp
78
+ raise WebhookVerificationError, "Missing required headers"
79
+ end
80
+
81
+ verify_timestamp(msg_timestamp)
82
+
83
+ _, signature = sign(msg_id, msg_timestamp, payload).split(",", 2)
84
+
85
+ passed_signatures = msg_signature.split(" ")
86
+
87
+ passed_signatures.each do |versioned_signature|
88
+ version, expected_signature = versioned_signature.split(",", 2)
89
+
90
+ if version != "v1"
91
+ next
92
+ end
93
+
94
+ if ::StandardWebhooks::secure_compare(signature, expected_signature)
95
+ return JSON.parse(payload, symbolize_names: true)
96
+ end
97
+ end
98
+
99
+ raise WebhookVerificationError, "No matching signature found"
100
+ end
101
+
102
+ def sign(msg_id, timestamp, payload)
103
+ begin
104
+ now = Integer(timestamp)
105
+ rescue
106
+ raise WebhookSigningError, "Invalid timestamp"
107
+ end
108
+
109
+ to_sign = "#{msg_id}.#{timestamp}.#{payload}"
110
+ signature = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), @secret, to_sign)).strip
111
+
112
+ return "v1,#{signature}"
113
+ end
114
+
115
+ private
116
+
117
+ SECRET_PREFIX = "whsec_"
118
+ TOLERANCE = 5 * 60
119
+
120
+ def verify_timestamp(timestamp_header)
121
+ begin
122
+ now = Integer(Time.now)
123
+ timestamp = Integer(timestamp_header)
124
+ rescue
125
+ raise WebhookVerificationError, "Invalid Signature Headers"
126
+ end
127
+
128
+ if timestamp < (now - TOLERANCE)
129
+ raise WebhookVerificationError, "Message timestamp too old"
130
+ end
131
+
132
+ if timestamp > (now + TOLERANCE)
133
+ raise WebhookVerificationError, "Message timestamp too new"
134
+ end
135
+ end
136
+ end
137
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polar_sh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
- original_platform: ''
7
6
  authors:
8
7
  - Mikkel Malmberg
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-12-20 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: http
@@ -24,6 +23,20 @@ dependencies:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
25
  version: '5'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ostruct
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.6'
27
40
  description: Interact with the Polar API
28
41
  email:
29
42
  - mikkel@brnbw.com
@@ -40,17 +53,24 @@ files:
40
53
  - lib/polar/configuration.rb
41
54
  - lib/polar/error.rb
42
55
  - lib/polar/resource.rb
56
+ - lib/polar/resources/benefit.rb
57
+ - lib/polar/resources/benefit_grant.rb
43
58
  - lib/polar/resources/checkout.rb
44
59
  - lib/polar/resources/checkout/custom.rb
45
60
  - lib/polar/resources/customer.rb
46
61
  - lib/polar/resources/customer_session.rb
47
62
  - lib/polar/resources/discount.rb
48
63
  - lib/polar/resources/license_key.rb
64
+ - lib/polar/resources/order.rb
49
65
  - lib/polar/resources/organization.rb
50
66
  - lib/polar/resources/product.rb
67
+ - lib/polar/resources/refund.rb
68
+ - lib/polar/resources/subscription.rb
51
69
  - lib/polar/resources/user.rb
52
70
  - lib/polar/version.rb
71
+ - lib/polar/webhook.rb
53
72
  - lib/polar_sh.rb
73
+ - lib/standard_webhooks.rb
54
74
  homepage: https://github.com/mikker/polar_sh
55
75
  licenses:
56
76
  - MIT
@@ -72,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
92
  - !ruby/object:Gem::Version
73
93
  version: '0'
74
94
  requirements: []
75
- rubygems_version: 3.6.1
95
+ rubygems_version: 3.6.9
76
96
  specification_version: 4
77
97
  summary: API client for Polar.sh
78
98
  test_files: []