polar_sh 0.1.0 → 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: 371122cf10062244b6f2cafb31ac73d645e49841b0bad4ecac8a518c9af1b28f
4
- data.tar.gz: ff56e2717b3ce2a12e049c0a03ab3e3e295380c407224f9cdface848af98a0e6
3
+ metadata.gz: 84bce050ddccdcfb7709e2815db0ead0e4591cca1fe007972be02b217c5172da
4
+ data.tar.gz: 30c54873668cabb778e3134b69b1ddf19c12fcbb72d75a65c3f43ad35d938cdc
5
5
  SHA512:
6
- metadata.gz: f840ba134ff22fa70445c3c7c7fe8b5a0328f82c4be9b399ee72384d69f64a36ed5267e9e0dad021db0e67937a6a1f61c44952552ad2ddef3e5c8f8407de80a0
7
- data.tar.gz: 53ceece89858ba6e827495753cd2a569084e5aa51f2edb4f518296fb3eff77740b1a6ce5819ba67175464fc841356929e3176929a3f21bdf0d402b5584f5fb30
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
@@ -1,38 +1,72 @@
1
- # PolarSh
1
+ # Polar.sh Ruby API client
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/polar_sh`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Still in development. [API docs](https://docs.polar.sh/api)
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
-
11
- Install the gem and add to the application's Gemfile by executing:
12
-
13
7
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
8
+ bundle add polar_sh
15
9
  ```
16
10
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
11
+ ## Usage
18
12
 
19
- ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
- ```
13
+ ```ruby
14
+ Polar.configure do |config|
15
+ config.access_token = "polar_..."
16
+ config.sandbox = true
17
+ config.webhook_secret = "xyz..."
18
+ end
22
19
 
23
- ## Usage
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
+ )
24
28
 
25
- TODO: Write usage instructions here
29
+ redirect_to(checkout.url, allow_other_host: true)
30
+ end
31
+ end
26
32
 
27
- ## Development
33
+ class WebhooksController < ApplicationController
34
+ skip_before_action :verify_authenticity_token
35
+
36
+ def handle_polar
37
+ event = Polar::Webhook.verify(request)
28
38
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
39
+ Rails.logger.info("Received Polar webhook: #{event.type}")
30
40
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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
58
+ ```
59
+
60
+ ## Development
61
+
62
+ ```sh
63
+ bundle
64
+ rake spec
65
+ ```
32
66
 
33
67
  ## Contributing
34
68
 
35
- 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>.
36
70
 
37
71
  ## License
38
72
 
data/lib/polar/client.rb CHANGED
@@ -9,7 +9,7 @@ module Polar
9
9
  @connection ||= HTTP
10
10
  .persistent(Polar.config.endpoint)
11
11
  .follow
12
- .auth("Bearer #{Polar.config.api_key}")
12
+ .auth("Bearer #{Polar.config.access_token}")
13
13
  .headers(
14
14
  accept: "application/json",
15
15
  content_type: "application/json",
@@ -2,8 +2,9 @@
2
2
 
3
3
  module Polar
4
4
  class Configuration
5
- attr_accessor :api_key
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.0"
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.0
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: []