├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── polar.rb ├── polar │ ├── client.rb │ ├── configuration.rb │ ├── error.rb │ ├── resource.rb │ ├── resources │ │ ├── benefit.rb │ │ ├── benefit_grant.rb │ │ ├── checkout.rb │ │ ├── checkout │ │ │ └── custom.rb │ │ ├── customer.rb │ │ ├── customer_session.rb │ │ ├── discount.rb │ │ ├── license_key.rb │ │ ├── order.rb │ │ ├── organization.rb │ │ ├── product.rb │ │ ├── refund.rb │ │ ├── subscription.rb │ │ └── user.rb │ ├── version.rb │ └── webhook.rb ├── polar_sh.rb └── standard_webhooks.rb ├── polar_sh.gemspec └── spec ├── polar ├── client_spec.rb ├── resource_spec.rb ├── resources │ ├── benefit_spec.rb │ ├── checkout │ │ └── custom_spec.rb │ ├── customer_session_spec.rb │ ├── customer_spec.rb │ ├── discount_spec.rb │ ├── license_key_spec.rb │ ├── order_spec.rb │ ├── organization_spec.rb │ ├── product_spec.rb │ ├── refund_spec.rb │ ├── subscription_spec.rb │ └── user_spec.rb └── webhook_spec.rb ├── polar_spec.rb ├── spec_helper.rb └── standard_webhooks_spec.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.3.6' 18 | env: 19 | POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} 20 | POLAR_ORGANIZATION_ID: ${{ secrets.POLAR_ORGANIZATION_ID }} 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: ${{ matrix.ruby }} 28 | bundler-cache: true 29 | - name: Run the default task 30 | run: bundle exec rake 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | spec/fixtures/vcr_cassettes/ 10 | Gemfile.lock 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0 2 | 3 | - Add `Polar::Webhook` 4 | - Add more resources 5 | 6 | ## 0.1.0 7 | 8 | - Initial release 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in polar_sh.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "webmock" 11 | gem "rspec" 12 | gem "vcr" 13 | gem "dotenv" 14 | gem "logger" 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Mikkel Malmberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polar.sh Ruby API client 2 | 3 | Still in development. [API docs](https://docs.polar.sh/api) 4 | 5 | ## Installation 6 | 7 | ```bash 8 | bundle add polar_sh 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```ruby 14 | Polar.configure do |config| 15 | config.access_token = "polar_..." 16 | config.sandbox = true 17 | config.webhook_secret = "xyz..." 18 | end 19 | 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 58 | ``` 59 | 60 | ## Development 61 | 62 | ```sh 63 | bundle 64 | rake spec 65 | ``` 66 | 67 | ## Contributing 68 | 69 | Bug reports and pull requests are welcome on GitHub at . 70 | 71 | ## License 72 | 73 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 74 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rspec/core/rake_task" 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | task(default: :spec) 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "polar_sh" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/polar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | require "http" 5 | 6 | require_relative "polar/version" 7 | require "standard_webhooks" 8 | 9 | module Polar 10 | autoload :Configuration, "polar/configuration" 11 | autoload :Client, "polar/client" 12 | autoload :Error, "polar/error" 13 | autoload :Resource, "polar/resource" 14 | autoload :Webhook, "polar/webhook" 15 | 16 | autoload :Benefit, "polar/resources/benefit" 17 | autoload :BenefitGrant, "polar/resources/benefit_grant" 18 | autoload :Customer, "polar/resources/customer" 19 | autoload :CustomerSession, "polar/resources/customer_session" 20 | autoload :Discount, "polar/resources/discount" 21 | autoload :Checkout, "polar/resources/checkout" 22 | autoload :LicenseKey, "polar/resources/license_key" 23 | autoload :Order, "polar/resources/order" 24 | autoload :Organization, "polar/resources/organization" 25 | autoload :Product, "polar/resources/product" 26 | autoload :Refund, "polar/resources/refund" 27 | autoload :Subscription, "polar/resources/subscription" 28 | autoload :User, "polar/resources/user" 29 | 30 | class << self 31 | attr_writer :config 32 | end 33 | 34 | def self.configure 35 | yield(config) if block_given? 36 | end 37 | 38 | def self.config 39 | @config ||= Configuration.new 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/polar/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module Polar 6 | class Client 7 | class << self 8 | def connection 9 | @connection ||= HTTP 10 | .persistent(Polar.config.endpoint) 11 | .follow 12 | .auth("Bearer #{Polar.config.access_token}") 13 | .headers( 14 | accept: "application/json", 15 | content_type: "application/json", 16 | user_agent: "polar_sh/v#{VERSION} (github.com/mikker/polar_sh)" 17 | ) 18 | # .use(logging: {logger: Logger.new(STDOUT)}) 19 | end 20 | 21 | def get_request(path, **params) 22 | response = connection.get(path, params:) 23 | return response.parse if response.status.success? 24 | raise Error.from_response(response) 25 | end 26 | 27 | def post_request(path, **params) 28 | response = connection.post(path, json: params) 29 | return response.parse if response.status.success? 30 | raise Error.from_response(response) 31 | end 32 | 33 | def patch_request(path, **params) 34 | response = connection.patch(path, json: params) 35 | return response.parse if response.status.success? 36 | raise Error.from_response(response) 37 | end 38 | 39 | def delete_request(path, **params) 40 | response = connection.delete(path, json: params) 41 | return true if response.status.success? 42 | raise Error.from_response(response) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/polar/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Configuration 5 | attr_accessor :access_token 6 | attr_accessor :sandbox 7 | attr_accessor :webhook_secret 8 | 9 | alias sandbox? sandbox 10 | 11 | def endpoint 12 | "https://#{sandbox? ? "sandbox-api" : "api"}.polar.sh" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/polar/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Error < StandardError 5 | class PolarError < OpenStruct 6 | end 7 | 8 | def initialize(message, error: nil, status: nil) 9 | @message = message 10 | @error = error 11 | @status = status 12 | end 13 | 14 | attr_reader :message, :error, :status 15 | 16 | def self.from_response(response) 17 | error = PolarError.new(response.parse) 18 | new(name_for(response.status), error:, status: response.status) 19 | end 20 | 21 | def message 22 | super + ": #{@error.inspect}" 23 | end 24 | 25 | class << self 26 | private 27 | 28 | def name_for(status) 29 | case status 30 | when 400 31 | "Bad Request" 32 | when 401 33 | "Unauthorized" 34 | when 403 35 | "Forbidden" 36 | when 404 37 | "Not Found" 38 | when 422 39 | "Unprocessable Entity" 40 | when 429 41 | "Too Many Requests" 42 | when 500 43 | "Internal Server Error" 44 | when 503 45 | "Service Unavailable" 46 | else 47 | "Unknown Error" 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/polar/resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Resource < OpenStruct 5 | private 6 | 7 | class << self 8 | def handle_list(response, klass = nil) 9 | klass ||= self 10 | response.fetch("items").map { |attributes| klass.new(attributes) } 11 | end 12 | 13 | def handle_one(response, klass = nil) 14 | klass ||= self 15 | klass.new(response) 16 | end 17 | 18 | def handle_none(response) 19 | response 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/polar/resources/benefit.rb: -------------------------------------------------------------------------------- 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 | 32 | -------------------------------------------------------------------------------- /lib/polar/resources/benefit_grant.rb: -------------------------------------------------------------------------------- 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 11 | -------------------------------------------------------------------------------- /lib/polar/resources/checkout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Checkout < Resource 5 | autoload :Custom, "polar/resources/checkout/custom" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/polar/resources/checkout/custom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Checkout 5 | class Custom < Resource 6 | def self.list(params = {}) 7 | response = Client.get_request("/v1/checkouts/custom/", **params) 8 | handle_list(response, Checkout) 9 | end 10 | 11 | def self.create(params) 12 | params[:payment_processor] ||= "stripe" 13 | response = Client.post_request("/v1/checkouts/custom/", **params) 14 | handle_one(response, Checkout) 15 | end 16 | 17 | def self.get(id) 18 | response = Client.get_request("/v1/checkouts/custom/#{id}") 19 | handle_one(response, Checkout) 20 | end 21 | 22 | def self.update(id, params) 23 | response = Client.patch_request("/v1/checkouts/custom/#{id}", **params) 24 | handle_one(response, Checkout) 25 | end 26 | 27 | def self.client_get(client_secret) 28 | response = Client.get_request("/v1/checkouts/custom/client/#{client_secret}") 29 | handle_one(response, Checkout) 30 | end 31 | 32 | def self.client_update(client_secret, params) 33 | response = Client.patch_request("/v1/checkouts/custom/client/#{client_secret}", **params) 34 | handle_one(response, Checkout) 35 | end 36 | 37 | def self.client_confirm(client_secret) 38 | response = Client.post_request("/v1/checkouts/custom/client/#{client_secret}/confirm") 39 | handle_one(response, Checkout) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/polar/resources/customer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Customer < Resource 5 | def self.list(params = {}) 6 | response = Client.get_request("/v1/customers", **params) 7 | handle_list(response) 8 | end 9 | 10 | def self.create(params) 11 | response = Client.post_request("/v1/customers", **params) 12 | handle_one(response) 13 | end 14 | 15 | def self.get(id) 16 | response = Client.get_request("/v1/customers/#{id}") 17 | handle_one(response) 18 | end 19 | 20 | def self.update(id, params) 21 | response = Client.patch_request("/v1/customers/#{id}", **params) 22 | handle_one(response) 23 | end 24 | 25 | def self.delete(id) 26 | response = Client.delete_request("/v1/customers/#{id}") 27 | handle_none(response) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/polar/resources/customer_session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class CustomerSession < Resource 5 | def self.create(params) 6 | response = Client.post_request("/v1/customer-sessions", **params) 7 | handle_one(response) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/polar/resources/discount.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Discount < Resource 5 | def self.list(params = {}) 6 | response = Client.get_request("/v1/discounts", **params) 7 | handle_list(response) 8 | end 9 | 10 | def self.create(params) 11 | response = Client.post_request("/v1/discounts", **params) 12 | handle_one(response) 13 | end 14 | 15 | def self.get(id) 16 | response = Client.get_request("/v1/discounts/#{id}") 17 | handle_one(response) 18 | end 19 | 20 | def self.update(id, params) 21 | response = Client.patch_request("/v1/discounts/#{id}", **params) 22 | handle_one(response) 23 | end 24 | 25 | def self.delete(id) 26 | response = Client.delete_request("/v1/discounts/#{id}") 27 | handle_none(response) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/polar/resources/license_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class LicenseKey < Resource 5 | def self.list(params = {}) 6 | response = Client.get_request("/v1/license-keys", **params) 7 | handle_list(response) 8 | end 9 | 10 | def self.get(id) 11 | response = Client.get_request("/v1/license-keys/#{id}") 12 | handle_one(response) 13 | end 14 | 15 | def self.update(id, params) 16 | response = Client.patch_request("/v1/license-keys/#{id}", **params) 17 | handle_one(response) 18 | end 19 | 20 | def self.get_activation(id, activation_id) 21 | response = Client.get_request("/v1/license-keys/#{id}/activations/#{activation_id}") 22 | handle_one(response) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/polar/resources/order.rb: -------------------------------------------------------------------------------- 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 21 | -------------------------------------------------------------------------------- /lib/polar/resources/organization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Organization < Resource 5 | def self.list(params = {}) 6 | response = Client.get_request("/v1/organizations", **params) 7 | handle_list(response) 8 | end 9 | 10 | def self.create(params = {}) 11 | response = Client.post_request("/v1/organizations", **params) 12 | handle_one(response) 13 | end 14 | 15 | def self.get(id) 16 | response = Client.get_request("/v1/organizations/#{id}") 17 | handle_one(response) 18 | end 19 | 20 | def self.update(id, params = {}) 21 | response = Client.patch_request("/v1/organizations/#{id}", **params) 22 | handle_one(response) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/polar/resources/product.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | class Product < Resource 5 | def self.list(params = {}) 6 | response = Client.get_request("/v1/products", **params) 7 | handle_list(response) 8 | end 9 | 10 | def self.create(params) 11 | response = Client.post_request("/v1/products", **params) 12 | handle_one(response) 13 | end 14 | 15 | def self.get(id) 16 | response = Client.get_request("/v1/products/#{id}") 17 | handle_one(response) 18 | end 19 | 20 | def self.update(id, params) 21 | response = Client.patch_request("/v1/products/#{id}", **params) 22 | handle_one(response) 23 | end 24 | 25 | def self.update_benefits(id, params) 26 | response = Client.post_request("/v1/products/#{id}/benefits", **params) 27 | handle_one(response) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/polar/resources/refund.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /lib/polar/resources/subscription.rb: -------------------------------------------------------------------------------- 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 | 53 | -------------------------------------------------------------------------------- /lib/polar/resources/user.rb: -------------------------------------------------------------------------------- 1 | module Polar 2 | class User < Resource 3 | # def self.me 4 | # response = Client.get_request("/v1/products") 5 | # pp(JSON.parse(response)) 6 | # new(response.body) if response.status.success? 7 | # end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/polar/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Polar 4 | VERSION = "0.2.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/polar/webhook.rb: -------------------------------------------------------------------------------- 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 50 | -------------------------------------------------------------------------------- /lib/polar_sh.rb: -------------------------------------------------------------------------------- 1 | require "polar" 2 | -------------------------------------------------------------------------------- /lib/standard_webhooks.rb: -------------------------------------------------------------------------------- 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 138 | -------------------------------------------------------------------------------- /polar_sh.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/polar/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "polar_sh" 7 | spec.version = Polar::VERSION 8 | spec.authors = ["Mikkel Malmberg"] 9 | spec.email = ["mikkel@brnbw.com"] 10 | 11 | spec.summary = "API client for Polar.sh" 12 | spec.description = "Interact with the Polar API" 13 | spec.homepage = "https://github.com/mikker/polar_sh" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.1.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = "https://github.com/mikker/polar_sh" 19 | spec.metadata["changelog_uri"] = "https://github.com/mikker/polar_sh/blob/main/CHANGELOG.md" 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | gemspec = File.basename(__FILE__) 24 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 25 | ls.readlines("\x0", chomp: true).reject do |f| 26 | (f == gemspec) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 27 | end 28 | end 29 | 30 | # spec.bindir = "exe" 31 | # spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | spec.add_dependency("http", "~> 5") 36 | spec.add_dependency("ostruct", "~> 0.6") 37 | # For more information and examples about making a new gem, check out our 38 | # guide at: https://bundler.io/guides/creating_gem.html 39 | end 40 | -------------------------------------------------------------------------------- /spec/polar/client_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Client do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/polar/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Resource do 5 | describe "#method_missing" do 6 | it "returns the value of the attribute" do 7 | resource = Resource.new(foo: "bar") 8 | expect(resource.foo).to(eq("bar")) 9 | end 10 | 11 | it "returns the value of the attribute" do 12 | resource = Resource.new(foo: "bar") 13 | expect(resource.bar).to be_nil 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/polar/resources/benefit_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.xdescribe(Benefit) do 5 | describe ".list" do 6 | it "returns a benefit list", :vcr do 7 | benefits = Polar::Benefit.list 8 | 9 | expect(benefits).to(be_a(Array)) 10 | expect(benefits.first).to(be_a(Polar::Benefit)) 11 | end 12 | 13 | it "takes params", :vcr do 14 | benefits = Polar::Benefit.list(query: "xyz") 15 | 16 | expect(benefits).to(be_a(Array)) 17 | expect(benefits.length).to be(0) 18 | end 19 | end 20 | 21 | describe ".create" do 22 | it "creates a benefit", :vcr do 23 | benefit = Polar::Benefit.create( 24 | name: "Test Benefit", 25 | type: "repository", 26 | organization_id: ENV["POLAR_ORGANIZATION_ID"] 27 | ) 28 | 29 | expect(benefit).to(be_a(Polar::Benefit)) 30 | expect(benefit.name).to(eq("Test Benefit")) 31 | end 32 | end 33 | 34 | describe ".get" do 35 | it "returns a benefit", :vcr do 36 | benefit = Polar::Benefit.get("ed45370a-0425-43ef-9262-5579e81460b8") 37 | 38 | expect(benefit).to(be_a(Polar::Benefit)) 39 | expect(benefit.name).to(eq("Test Benefit")) 40 | end 41 | end 42 | 43 | describe ".update" do 44 | it "updates a benefit", :vcr do 45 | benefit = Polar::Benefit.update( 46 | "ed45370a-0425-43ef-9262-5579e81460b8", 47 | {name: "Updated Benefit"} 48 | ) 49 | expect(benefit.name).to(eq("Updated Benefit")) 50 | 51 | # reset 52 | Polar::Benefit.update( 53 | "ed45370a-0425-43ef-9262-5579e81460b8", 54 | {name: "Test Benefit"} 55 | ) 56 | end 57 | end 58 | 59 | describe ".delete" do 60 | it "deletes a benefit", :vcr do 61 | benefit = Polar::Benefit.delete("ed45370a-0425-43ef-9262-5579e81460b8") 62 | expect(benefit.id).to(eq("ed45370a-0425-43ef-9262-5579e81460b8")) 63 | end 64 | end 65 | 66 | describe ".list_grants" do 67 | it "returns a grant list", :vcr do 68 | grants = Polar::Benefit.list_grants("ed45370a-0425-43ef-9262-5579e81460b8") 69 | 70 | expect(grants).to(be_a(Array)) 71 | end 72 | end 73 | end 74 | end 75 | 76 | -------------------------------------------------------------------------------- /spec/polar/resources/checkout/custom_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Checkout::Custom do 5 | describe ".list" do 6 | it "returns a checkout list", :vcr do 7 | checkouts = Polar::Checkout::Custom.list 8 | 9 | expect(checkouts).to(be_a(Array)) 10 | expect(checkouts.first).to(be_a(Polar::Checkout)) 11 | end 12 | end 13 | 14 | describe ".create" do 15 | it "creates a checkout session", :vcr do 16 | checkout = Polar::Checkout::Custom.create( 17 | product_id: "ed45370a-0425-43ef-9262-5579e81460b8" 18 | ) 19 | 20 | expect(checkout).to(be_a(Polar::Checkout)) 21 | expect(checkout.product["name"]).to(eq("Test Product")) 22 | end 23 | end 24 | 25 | describe ".get" do 26 | it "returns a checkout", :vcr do 27 | checkout = Polar::Checkout::Custom.get("e1c6b126-4ebd-4797-ab16-8b559b2cfb9a") 28 | 29 | expect(checkout).to(be_a(Polar::Checkout)) 30 | expect(checkout.product["name"]).to(eq("Test Product")) 31 | end 32 | end 33 | 34 | # TODO: 35 | # Doesn't seem to work. What params can be updated? 36 | xdescribe(".update") do 37 | it "updates a checkout", :vcr do 38 | checkout = Polar::Checkout::Custom.update( 39 | "e1c6b126-4ebd-4797-ab16-8b559b2cfb9a", 40 | {custom_field_data: {test: "Hi"}} 41 | ) 42 | expect(checkout.custom_field_data).to(eq(test: "Hi")) 43 | 44 | # reset 45 | Polar::Checkout::Custom.update("e1c6b126-4ebd-4797-ab16-8b559b2cfb9a", {customer_name: nil}) 46 | end 47 | end 48 | 49 | xdescribe(".client_update") do 50 | it "creates a checkout session", :vcr do 51 | checkout = Polar::Checkout::Custom.client_update( 52 | "polar_c_Tta5TxPD4nMXtCHvHFHezBXoqK0Wq88jkPfiv2gTKaE", 53 | customer_name: "Updated" 54 | ) 55 | 56 | expect(checkout).to(be_a(Polar::Checkout)) 57 | expect(checkout.customer_name).to(eq("Updated")) 58 | 59 | Polar::Checkout::Custom.client_update( 60 | "polar_c_Tta5TxPD4nMXtCHvHFHezBXoqK0Wq88jkPfiv2gTKaE", 61 | customer_name: "Test Customer" 62 | ) 63 | end 64 | end 65 | 66 | xdescribe(".clent_get") do 67 | it "returns a checkout", :vcr do 68 | checkout = Polar::Checkout::Custom.client_get("polar_c_Tta5TxPD4nMXtCHvHFHezBXoqK0Wq88jkPfiv2gTKaE") 69 | 70 | expect(checkout).to(be_a(Polar::Checkout)) 71 | expect(checkout.product["name"]).to(eq("Test Product")) 72 | end 73 | end 74 | 75 | # TODO: 76 | # Requires "higher level access token" 77 | # Is this an internal API only? 78 | xdescribe(".client_confirm") do 79 | it "returns a checkout", :vcr do 80 | checkout = Polar::Checkout::Custom.create(product_id: "ed45370a-0425-43ef-9262-5579e81460b8") 81 | checkout = Polar::Checkout::Custom.client_confirm(checkout.client_secret) 82 | expect(checkout).to(be_a(Polar::Checkout)) 83 | expect(checkout.status).to(eq("confirmed")) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/polar/resources/customer_session_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe CustomerSession do 5 | describe ".create" do 6 | it "creates a customer", :vcr do 7 | customer_session = Polar::CustomerSession.create( 8 | customer_id: "866f8b85-a39a-4b57-9f62-fbf2fb9d16c7" 9 | ) 10 | 11 | expect(customer_session).to(be_a(Polar::CustomerSession)) 12 | expect(customer_session.customer["name"]).to(eq("Test Customer")) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/polar/resources/customer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Customer do 5 | describe ".list" do 6 | it "returns a customer list", :vcr do 7 | customers = Polar::Customer.list 8 | 9 | expect(customers).to(be_a(Array)) 10 | expect(customers.first).to(be_a(Polar::Customer)) 11 | end 12 | end 13 | 14 | describe ".create" do 15 | it "creates a customer", vcr: {match_requests_on: [:method, :uri]} do 16 | customer = Polar::Customer.create( 17 | email: random_email, 18 | name: "Test Customer", 19 | organization_id: ENV["POLAR_ORGANIZATION_ID"] 20 | ) 21 | 22 | expect(customer).to(be_a(Polar::Customer)) 23 | expect(customer.name).to(eq("Test Customer")) 24 | 25 | # clean-up 26 | Polar::Customer.delete(customer.id) 27 | end 28 | end 29 | 30 | describe ".get" do 31 | it "returns a customer", :vcr do 32 | customer = Polar::Customer.get("866f8b85-a39a-4b57-9f62-fbf2fb9d16c7") 33 | 34 | expect(customer).to(be_a(Polar::Customer)) 35 | expect(customer.name).to(eq("Test Customer")) 36 | end 37 | end 38 | 39 | describe ".update" do 40 | it "updates a customer", :vcr do 41 | customer = Polar::Customer.update("866f8b85-a39a-4b57-9f62-fbf2fb9d16c7", {name: "Updated Customer"}) 42 | expect(customer.name).to(eq("Updated Customer")) 43 | 44 | # reset 45 | Polar::Customer.update("866f8b85-a39a-4b57-9f62-fbf2fb9d16c7", {name: "Test Customer"}) 46 | end 47 | end 48 | 49 | describe ".delete" do 50 | it "deletes a customer", :vcr do 51 | customer = Polar::Customer.create( 52 | name: "Test Customer", 53 | email: random_email, 54 | organization_id: ENV["POLAR_ORGANIZATION_ID"] 55 | ) 56 | 57 | id = customer.id 58 | 59 | Polar::Customer.delete(id) 60 | 61 | expect { Polar::Customer.get(id) }.to raise_error(Polar::Error) 62 | end 63 | end 64 | 65 | private 66 | 67 | def random_email 68 | "test-customer-#{SecureRandom.uuid}@polar.sh" 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/polar/resources/discount_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Discount do 5 | describe ".list" do 6 | it "returns a discount list", :vcr do 7 | discounts = Polar::Discount.list 8 | 9 | expect(discounts).to(be_a(Array)) 10 | expect(discounts.first).to(be_a(Polar::Discount)) 11 | end 12 | end 13 | 14 | describe ".create" do 15 | it "creates a discount", :vcr do 16 | discount = Polar::Discount.create( 17 | name: "Test Discount", 18 | duration: "forever", 19 | type: "percentage", 20 | basis_points: 100, 21 | organization_id: ENV["POLAR_ORGANIZATION_ID"] 22 | ) 23 | 24 | expect(discount).to(be_a(Polar::Discount)) 25 | expect(discount.name).to(eq("Test Discount")) 26 | end 27 | end 28 | 29 | describe ".get" do 30 | it "returns a discount", :vcr do 31 | discount = Polar::Discount.get("6e62566b-946f-45d8-842c-7c60a6474005") 32 | 33 | expect(discount).to(be_a(Polar::Discount)) 34 | expect(discount.name).to(eq("Test Discount")) 35 | end 36 | end 37 | 38 | describe ".update" do 39 | it "updates a discount", :vcr do 40 | discount = Polar::Discount.update("6e62566b-946f-45d8-842c-7c60a6474005", {name: "Updated Discount"}) 41 | expect(discount.name).to(eq("Updated Discount")) 42 | 43 | # reset 44 | Polar::Discount.update("6e62566b-946f-45d8-842c-7c60a6474005", {name: "Test Discount"}) 45 | end 46 | end 47 | 48 | describe ".delete" do 49 | it "deletes a discount", :vcr do 50 | discount = Polar::Discount.create( 51 | name: "Test Discount", 52 | duration: "forever", 53 | type: "percentage", 54 | basis_points: 100, 55 | organization_id: ENV["POLAR_ORGANIZATION_ID"] 56 | ) 57 | 58 | id = discount.id 59 | 60 | Polar::Discount.delete(id) 61 | 62 | expect { Polar::Discount.get(id) }.to raise_error(Polar::Error) 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/polar/resources/license_key_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe LicenseKey do 5 | describe ".list" do 6 | it "returns a license_key list", :vcr do 7 | license_keys = Polar::LicenseKey.list 8 | 9 | expect(license_keys).to(be_a(Array)) 10 | expect(license_keys.first).to(be_a(Polar::LicenseKey)) 11 | end 12 | end 13 | 14 | describe ".get" do 15 | it "returns a license_key", :vcr do 16 | license_key = Polar::LicenseKey.get("19fb4c80-359e-4321-b0b0-df0a55df7f29") 17 | 18 | expect(license_key).to(be_a(Polar::LicenseKey)) 19 | expect(license_key.display_key).to(eq("****-F6C647")) 20 | end 21 | end 22 | 23 | describe ".update" do 24 | it "updates a license_key", :vcr do 25 | license_key = Polar::LicenseKey.update("19fb4c80-359e-4321-b0b0-df0a55df7f29", {status: "revoked"}) 26 | expect(license_key.status).to(eq("revoked")) 27 | 28 | # reset 29 | Polar::LicenseKey.update("19fb4c80-359e-4321-b0b0-df0a55df7f29", {status: "granted"}) 30 | end 31 | end 32 | 33 | # Not sure how to create an activation 34 | xdescribe(".get_activation") do 35 | it "returns a license_key", :vcr do 36 | license_key = Polar::LicenseKey.get_activation("19fb4c80-359e-4321-b0b0-df0a55df7f29", "x") 37 | 38 | expect(license_key).to(be_a(Polar::LicenseKey)) 39 | expect(license_key.name).to(eq("Test LicenseKey")) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/polar/resources/order_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Order do 5 | describe ".list" do 6 | it "returns a order list", :vcr do 7 | orders = Polar::Order.list 8 | 9 | expect(orders).to(be_a(Array)) 10 | expect(orders.first).to(be_a(Polar::Order)) 11 | end 12 | end 13 | 14 | describe ".get" do 15 | it "returns a order", :vcr do 16 | order = Polar::Order.get("89154ef0-35d3-416d-ae7b-2f55bab1c6e5") 17 | 18 | expect(order).to(be_a(Polar::Order)) 19 | expect(order.id).to(eq("89154ef0-35d3-416d-ae7b-2f55bab1c6e5")) 20 | end 21 | end 22 | 23 | # TODO: How to generate test data? 24 | xdescribe(".get_invoice") do 25 | it "returns an invoice", :vcr do 26 | invoice = Polar::Order.get_invoice("89154ef0-35d3-416d-ae7b-2f55bab1c6e5") 27 | 28 | expect(invoice).to(be_a(Polar::Invoice)) 29 | expect(invoice.id).to(eq("89154ef0-35d3-416d-ae7b-2f55bab1c6e5")) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/polar/resources/organization_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Organization do 5 | describe ".list" do 6 | it "returns a organization list", :vcr do 7 | organizations = Polar::Organization.list 8 | 9 | expect(organizations).to(be_a(Array)) 10 | expect(organizations.first).to(be_a(Polar::Organization)) 11 | end 12 | 13 | it "takes params", :vcr do 14 | organizations = Polar::Organization.list(slug: "xyz") 15 | 16 | expect(organizations).to(be_a(Array)) 17 | expect(organizations.length).to be(0) 18 | end 19 | end 20 | 21 | describe ".create" do 22 | it "creates a organization", :vcr do 23 | organization = Polar::Organization.create( 24 | name: "Test Organization", 25 | slug: "test-org-#{rand(100)}" 26 | ) 27 | 28 | expect(organization).to(be_a(Polar::Organization)) 29 | expect(organization.name).to(eq("Test Organization")) 30 | end 31 | end 32 | 33 | describe ".get" do 34 | it "returns a organization", :vcr do 35 | organization = Polar::Organization.get("e6b79af2-386f-4f34-901c-1078dcd2641f") 36 | 37 | expect(organization).to(be_a(Polar::Organization)) 38 | expect(organization.name).to(eq("Brainbow-sandbox")) 39 | end 40 | end 41 | 42 | describe ".update" do 43 | it "updates a organization", :vcr do 44 | organization = Polar::Organization.update( 45 | "e6b79af2-386f-4f34-901c-1078dcd2641f", 46 | {name: "Updated Organization"} 47 | ) 48 | expect(organization.name).to(eq("Updated Organization")) 49 | 50 | # reset 51 | Polar::Organization.update("e6b79af2-386f-4f34-901c-1078dcd2641f", {name: "Brainbow-sandbox"}) 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/polar/resources/product_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.describe Product do 5 | describe ".list" do 6 | it "returns a product list", :vcr do 7 | products = Polar::Product.list 8 | 9 | expect(products).to(be_a(Array)) 10 | expect(products.first).to(be_a(Polar::Product)) 11 | end 12 | 13 | it "takes params", :vcr do 14 | products = Polar::Product.list(query: "xyz") 15 | 16 | expect(products).to(be_a(Array)) 17 | expect(products.length).to be(0) 18 | end 19 | end 20 | 21 | describe ".create" do 22 | it "creates a product", :vcr do 23 | product = Polar::Product.create( 24 | name: "Test Product", 25 | prices: [{type: "one_time", amount_type: "fixed", price_amount: 100_00}], 26 | organization_id: ENV["POLAR_ORGANIZATION_ID"] 27 | ) 28 | 29 | expect(product).to(be_a(Polar::Product)) 30 | expect(product.name).to(eq("Test Product")) 31 | end 32 | end 33 | 34 | describe ".get" do 35 | it "returns a product", :vcr do 36 | product = Polar::Product.get("ed45370a-0425-43ef-9262-5579e81460b8") 37 | 38 | expect(product).to(be_a(Polar::Product)) 39 | expect(product.name).to(eq("Test Product")) 40 | end 41 | end 42 | 43 | describe ".update" do 44 | it "updates a product", :vcr do 45 | product = Polar::Product.update("ed45370a-0425-43ef-9262-5579e81460b8", {name: "Updated Product"}) 46 | expect(product.name).to(eq("Updated Product")) 47 | 48 | # reset 49 | Polar::Product.update("ed45370a-0425-43ef-9262-5579e81460b8", {name: "Test Product"}) 50 | end 51 | end 52 | 53 | describe ".update_benefits" do 54 | it "updates a product benefits", :vcr do 55 | benefits_before = Product.get("ed45370a-0425-43ef-9262-5579e81460b8").benefits 56 | 57 | product = Polar::Product.update_benefits( 58 | "ed45370a-0425-43ef-9262-5579e81460b8", 59 | {benefits: []} 60 | ) 61 | expect(product.benefits).to eq([]) 62 | 63 | # reset 64 | Polar::Product.update_benefits( 65 | "ed45370a-0425-43ef-9262-5579e81460b8", 66 | benefits: benefits_before 67 | ) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/polar/resources/refund_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Polar 4 | RSpec.xdescribe(Refund) do 5 | describe ".list" do 6 | it "returns a refund list", :vcr do 7 | refunds = Polar::Refund.list 8 | 9 | expect(refunds).to(be_a(Array)) 10 | expect(refunds.first).to(be_a(Polar::Refund)) 11 | end 12 | 13 | it "takes params", :vcr do 14 | refunds = Polar::Refund.list(query: "xyz") 15 | 16 | expect(refunds).to(be_a(Array)) 17 | expect(refunds.length).to be(0) 18 | end 19 | end 20 | 21 | describe ".create" do 22 | it "creates a refund", :vcr do 23 | refund = Polar::Refund.create( 24 | order_id: "ed45370a-0425-43ef-9262-5579e81460b8", 25 | amount: 100_00, 26 | reason: "customer_requested" 27 | ) 28 | 29 | expect(refund).to(be_a(Polar::Refund)) 30 | expect(refund.amount).to(eq(100_00)) 31 | end 32 | end 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /spec/polar/resources/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Polar 6 | RSpec.xdescribe(Subscription) do 7 | # These are Cursor generated stubs for when we figure out how to generate test data 8 | describe ".list" do 9 | it "returns a subscription list", :vcr do 10 | subscriptions = Polar::Subscription.list 11 | 12 | expect(subscriptions).to(be_a(Array)) 13 | expect(subscriptions.first).to(be_a(Polar::Subscription)) 14 | end 15 | 16 | it "takes params", :vcr do 17 | subscriptions = Polar::Subscription.list(query: "xyz") 18 | 19 | expect(subscriptions).to(be_a(Array)) 20 | expect(subscriptions.length).to be(0) 21 | end 22 | end 23 | 24 | describe ".export" do 25 | it "returns a subscription export list", :vcr do 26 | subscriptions = Polar::Subscription.export 27 | 28 | expect(subscriptions).to(be_a(Array)) 29 | expect(subscriptions.first).to(be_a(Polar::Subscription)) 30 | end 31 | 32 | it "takes params", :vcr do 33 | subscriptions = Polar::Subscription.export(query: "xyz") 34 | 35 | expect(subscriptions).to(be_a(Array)) 36 | expect(subscriptions.length).to be(0) 37 | end 38 | end 39 | 40 | describe ".get" do 41 | it "returns a subscription", :vcr do 42 | subscription = Polar::Subscription.get("sub_01HNW7HXRN4WQRM5GDGPZ42K2N") 43 | 44 | expect(subscription).to(be_a(Polar::Subscription)) 45 | expect(subscription.id).to(eq("sub_01HNW7HXRN4WQRM5GDGPZ42K2N")) 46 | end 47 | end 48 | 49 | describe ".update" do 50 | it "updates a subscription", :vcr do 51 | subscription = Polar::Subscription.update( 52 | "sub_01HNW7HXRN4WQRM5GDGPZ42K2N", 53 | {status: "active"} 54 | ) 55 | expect(subscription.status).to(eq("active")) 56 | 57 | # reset 58 | Polar::Subscription.update( 59 | "sub_01HNW7HXRN4WQRM5GDGPZ42K2N", 60 | {status: "inactive"} 61 | ) 62 | end 63 | end 64 | 65 | describe ".delete" do 66 | it "deletes a subscription", :vcr do 67 | subscription = Polar::Subscription.delete("sub_01HNW7HXRN4WQRM5GDGPZ42K2N") 68 | expect(subscription.id).to(eq("sub_01HNW7HXRN4WQRM5GDGPZ42K2N")) 69 | end 70 | end 71 | 72 | describe ".list_for_customer" do 73 | it "returns a subscription list for customer", :vcr do 74 | subscriptions = Polar::Subscription.list_for_customer 75 | 76 | expect(subscriptions).to(be_a(Array)) 77 | expect(subscriptions.first).to(be_a(Polar::Subscription)) 78 | end 79 | 80 | it "takes params", :vcr do 81 | subscriptions = Polar::Subscription.list_for_customer(query: "xyz") 82 | 83 | expect(subscriptions).to(be_a(Array)) 84 | expect(subscriptions.length).to be(0) 85 | end 86 | end 87 | 88 | describe ".get_for_customer" do 89 | it "returns a subscription for customer", :vcr do 90 | subscription = Polar::Subscription.get_for_customer("sub_01HNW7HXRN4WQRM5GDGPZ42K2N") 91 | 92 | expect(subscription).to(be_a(Polar::Subscription)) 93 | expect(subscription.id).to(eq("sub_01HNW7HXRN4WQRM5GDGPZ42K2N")) 94 | end 95 | end 96 | 97 | describe ".update_for_customer" do 98 | it "updates a subscription for customer", :vcr do 99 | subscription = Polar::Subscription.update_for_customer( 100 | "sub_01HNW7HXRN4WQRM5GDGPZ42K2N", 101 | {status: "active"} 102 | ) 103 | expect(subscription.status).to(eq("active")) 104 | 105 | # reset 106 | Polar::Subscription.update_for_customer( 107 | "sub_01HNW7HXRN4WQRM5GDGPZ42K2N", 108 | {status: "inactive"} 109 | ) 110 | end 111 | end 112 | 113 | describe ".delete_for_customer" do 114 | it "deletes a subscription for customer", :vcr do 115 | subscription = Polar::Subscription.delete_for_customer("sub_01HNW7HXRN4WQRM5GDGPZ42K2N") 116 | expect(subscription.id).to(eq("sub_01HNW7HXRN4WQRM5GDGPZ42K2N")) 117 | end 118 | end 119 | end 120 | end 121 | 122 | -------------------------------------------------------------------------------- /spec/polar/resources/user_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Polar::User do 4 | # describe ".me" do 5 | # it "returns a user", :vcr do 6 | # user = Polar::User.me 7 | # 8 | # expect(user).to(be_a(Polar::User)) 9 | # expect(user.id).to(eq("123")) 10 | # end 11 | # end 12 | end 13 | -------------------------------------------------------------------------------- /spec/polar/webhook_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Polar::Webhook do 4 | # Base64 encoded "secret" 5 | let(:webhook_secret) { "c2VjcmV0" } 6 | let(:payload) { {type: "test"} } 7 | 8 | let(:headers) do 9 | { 10 | "webhook-id" => "123", 11 | "webhook-timestamp" => Time.now.to_i.to_s, 12 | "webhook-signature" => "dummy-signature" 13 | } 14 | end 15 | 16 | let(:request) do 17 | double( 18 | raw_post: payload.to_json, 19 | headers: headers 20 | ) 21 | end 22 | 23 | before do 24 | allow(Polar.config).to(receive(:webhook_secret).and_return(webhook_secret)) 25 | end 26 | 27 | describe "#initialize" do 28 | it "uses the configured secret when none is provided" do 29 | expect(described_class.new(request).secret).to(eq(Base64.encode64(webhook_secret))) 30 | expect(described_class.new(request, secret: "abc").secret).to(eq(Base64.encode64("abc"))) 31 | end 32 | end 33 | 34 | describe "#verify" do 35 | it "returns self if the event is valid" do 36 | allow(StandardWebhooks::Webhook).to(receive(:new)).and_return(double(verify: payload)) 37 | expect(described_class.new(request).verify).to(be_a(described_class)) 38 | end 39 | 40 | it "returns false if the event is invalid" do 41 | allow(StandardWebhooks::Webhook).to(receive(:new)).and_raise( 42 | StandardWebhooks::StandardWebhooksError.new("Invalid") 43 | ) 44 | expect { described_class.new(request).verify }.to(raise_error(StandardWebhooks::StandardWebhooksError)) 45 | end 46 | 47 | context("with different resource types") do 48 | let(:webhook) { StandardWebhooks::Webhook.new(webhook_secret) } 49 | 50 | before do 51 | allow(StandardWebhooks::Webhook).to(receive(:new)).and_return(double(verify: {})) 52 | end 53 | 54 | { 55 | "checkout.created" => Polar::Checkout::Custom, 56 | "order.created" => Polar::Order, 57 | "product.created" => Polar::Product, 58 | "organization.created" => Polar::Organization, 59 | "subscription.created" => Polar::Subscription, 60 | "refund.created" => Polar::Refund, 61 | "benefit.created" => Polar::Benefit 62 | }.each do |type, klass| 63 | context("when type is #{type}") do 64 | let(:payload) { {type:, data: {}} } 65 | 66 | it "handles the #{type} event" do 67 | result = described_class.new(request).verify 68 | expect(result.type).to(eq(type)) 69 | expect(result.payload).to(eq(payload)) 70 | expect(result.object).to(be_a(klass)) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/polar_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Polar do 4 | describe ".configure" do 5 | it "yields the configuration" do 6 | Polar.configure do |config| 7 | expect(config).to(be_a(Polar::Configuration)) 8 | 9 | config.access_token = "123" 10 | end 11 | 12 | expect(Polar.config.access_token).to(eq("123")) 13 | end 14 | 15 | it "can use sandbox" do 16 | expect(Polar.config.endpoint).to(eq("https://sandbox-api.polar.sh")) 17 | 18 | Polar.configure do |config| 19 | config.sandbox = false 20 | end 21 | 22 | expect(Polar.config.endpoint).to(eq("https://api.polar.sh")) 23 | ensure 24 | Polar.configure do |config| 25 | config.sandbox = true 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "polar_sh" 2 | 3 | require "vcr" 4 | require "dotenv/load" 5 | 6 | VCR.configure do |config| 7 | config.cassette_library_dir = "spec/fixtures/vcr_cassettes" 8 | config.hook_into(:webmock) 9 | config.filter_sensitive_data("") { ENV["POLAR_ACCESS_TOKEN"] } 10 | config.configure_rspec_metadata! 11 | end 12 | 13 | Polar.configure do |config| 14 | config.sandbox = true 15 | config.access_token = ENV["POLAR_ACCESS_TOKEN"] 16 | end 17 | 18 | RSpec.configure do |config| 19 | end 20 | -------------------------------------------------------------------------------- /spec/standard_webhooks_spec.rb: -------------------------------------------------------------------------------- 1 | require "standard_webhooks" 2 | 3 | DEFAULT_MSG_ID = "msg_p5jXN8AQM9LWM0D4loKWxJek" 4 | DEFAULT_PAYLOAD = "{\"test\": 2432232314}" 5 | DEFAULT_SECRET = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" 6 | TOLERANCE = 5 * 60 7 | 8 | class TestPayload 9 | 10 | def initialize(timestamp = Time.now.to_i) 11 | @secret = DEFAULT_SECRET 12 | 13 | @id = DEFAULT_MSG_ID 14 | @timestamp = timestamp 15 | 16 | @payload = DEFAULT_PAYLOAD 17 | @secret = DEFAULT_SECRET 18 | 19 | toSign = "#{@id}.#{@timestamp}.#{@payload}" 20 | @signature = Base64 21 | .encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), Base64.decode64(DEFAULT_SECRET), toSign)) 22 | .strip 23 | 24 | @headers = { 25 | "webhook-id" => @id, 26 | "webhook-signature" => "v1,#{@signature}", 27 | "webhook-timestamp" => @timestamp 28 | } 29 | end 30 | 31 | attr_accessor :secret 32 | attr_accessor :id 33 | attr_accessor :timestamp 34 | attr_accessor :payload 35 | attr_accessor :signature 36 | attr_accessor :headers 37 | end 38 | 39 | describe StandardWebhooks::Webhook do 40 | it "missing id raises error" do 41 | testPayload = TestPayload.new 42 | testPayload.headers.delete("webhook-id") 43 | 44 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 45 | 46 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 47 | raise_error(StandardWebhooks::WebhookVerificationError) 48 | ) 49 | end 50 | 51 | it "missing timestamp raises error" do 52 | testPayload = TestPayload.new 53 | testPayload.headers.delete("webhook-timestamp") 54 | 55 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 56 | 57 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 58 | raise_error(StandardWebhooks::WebhookVerificationError) 59 | ) 60 | end 61 | 62 | it "missing signature raises error" do 63 | testPayload = TestPayload.new 64 | testPayload.headers.delete("webhook-signature") 65 | 66 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 67 | 68 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 69 | raise_error(StandardWebhooks::WebhookVerificationError) 70 | ) 71 | end 72 | 73 | it "invalid signature raises error" do 74 | testPayload = TestPayload.new 75 | testPayload.headers["webhook-signature"] = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLawdd" 76 | 77 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 78 | 79 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 80 | raise_error(StandardWebhooks::WebhookVerificationError) 81 | ) 82 | end 83 | 84 | it "valid signature is valid and returns valid json" do 85 | testPayload = TestPayload.new 86 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 87 | 88 | json = wh.verify(testPayload.payload, testPayload.headers) 89 | expect(json[:test]).to(eq(2432232314)) 90 | end 91 | 92 | it "valid unbranded signature is valid and returns valid json" do 93 | testPayload = TestPayload.new 94 | unbrandedHeaders = { 95 | "webhook-id" => testPayload.headers["webhook-id"], 96 | "webhook-signature" => testPayload.headers["webhook-signature"], 97 | "webhook-timestamp" => testPayload.headers["webhook-timestamp"] 98 | } 99 | testPayload.headers = unbrandedHeaders 100 | 101 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 102 | 103 | json = wh.verify(testPayload.payload, testPayload.headers) 104 | expect(json[:test]).to(eq(2432232314)) 105 | end 106 | 107 | it "old timestamp raises error" do 108 | testPayload = TestPayload.new(Time.now.to_i - TOLERANCE - 1) 109 | 110 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 111 | 112 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 113 | raise_error(StandardWebhooks::WebhookVerificationError) 114 | ) 115 | end 116 | 117 | it "new timestamp raises error" do 118 | testPayload = TestPayload.new(Time.now.to_i + TOLERANCE + 1) 119 | 120 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 121 | 122 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 123 | raise_error(StandardWebhooks::WebhookVerificationError) 124 | ) 125 | end 126 | 127 | it "invalid timestamp raises error" do 128 | testPayload = TestPayload.new("teadwd") 129 | 130 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 131 | 132 | expect { wh.verify(testPayload.payload, testPayload.headers) }.to( 133 | raise_error(StandardWebhooks::WebhookVerificationError) 134 | ) 135 | end 136 | 137 | it "multi sig pyload is valid" do 138 | testPayload = TestPayload.new 139 | sigs = [ 140 | "v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=", 141 | "v2,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=", 142 | # valid signature 143 | testPayload.headers["webhook-signature"], 144 | "v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=" 145 | ] 146 | testPayload.headers["webhook-signature"] = sigs.join(" ") 147 | 148 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 149 | 150 | json = wh.verify(testPayload.payload, testPayload.headers) 151 | expect(json[:test]).to(eq(2432232314)) 152 | end 153 | 154 | it "signature verification works with and without prefix" do 155 | testPayload = TestPayload.new 156 | 157 | wh = StandardWebhooks::Webhook.new(testPayload.secret) 158 | json = wh.verify(testPayload.payload, testPayload.headers) 159 | expect(json[:test]).to(eq(2432232314)) 160 | 161 | wh = StandardWebhooks::Webhook.new("whsec_" + testPayload.secret) 162 | json = wh.verify(testPayload.payload, testPayload.headers) 163 | expect(json[:test]).to(eq(2432232314)) 164 | end 165 | 166 | it "sign function works" do 167 | key = "whsec_LaLaLaLaLaLaLaLaLaLaLaLaLaLaLaLa" 168 | msg_id = "msg_p5jXN8AQM9LWM0D4loKWxJek" 169 | timestamp = 1614265330 170 | payload = "{\"test\": 2432232314}" 171 | expected = "v1,XMfT6uckOmtI3onRD7A9sBn/TQCpCruZIdxPo6hFggQ=" 172 | 173 | wh = StandardWebhooks::Webhook.new(key) 174 | signature = wh.sign(msg_id, timestamp, payload) 175 | expect(signature).to(eq(expected)) 176 | end 177 | end 178 | --------------------------------------------------------------------------------