diff --git a/Gemfile b/Gemfile index 8b3cb86d..9bd2beff 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,7 @@ gem "vcr" gem "webmock" gem "braintree", ">= 2.92.0" +gem "lemonsqueezy", "~> 1.0" gem "paddle", "~> 2.4" gem "stripe", "~> 12.0" diff --git a/Gemfile.lock b/Gemfile.lock index a20bb8f9..1f7e349b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -132,6 +132,8 @@ GEM reline (>= 0.4.2) json (2.7.2) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) logger (1.6.0) loofah (2.22.0) @@ -327,6 +329,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap diff --git a/README.md b/README.md index 60149c6d..251c4b35 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Our supported payment processors are: - Stripe ([SCA Compatible](https://stripe.com/docs/strong-customer-authentication) using API version `2022-11-15`) - Paddle (SCA Compatible & supports PayPal) - Braintree (supports PayPal) +- Lemon Squeezy (supports PayPal) - [Fake Processor](docs/fake_processor/1_overview.md) (used for generic trials without cards, free subscriptions, testing, etc) Want to add a new payment provider? Contributions are welcome. @@ -45,6 +46,7 @@ Want to add a new payment provider? Contributions are welcome. * [Stripe](docs/stripe/1_overview.md) * [Braintree](docs/braintree/1_overview.md) * [Paddle](docs/paddle_billing/1_overview.md) + * [Lemon Squeezy](docs/lemon_squeezy/1_overview.md) * [Fake Processor](docs/fake_processor/1_overview.md) * **Marketplaces** * [Stripe Connect](docs/marketplaces/stripe_connect.md) diff --git a/app/controllers/pay/webhooks/lemon_squeezy_controller.rb b/app/controllers/pay/webhooks/lemon_squeezy_controller.rb new file mode 100644 index 00000000..87f4f9ef --- /dev/null +++ b/app/controllers/pay/webhooks/lemon_squeezy_controller.rb @@ -0,0 +1,45 @@ +module Pay + module Webhooks + class LemonSqueezyController < Pay::ApplicationController + if Rails.application.config.action_controller.default_protect_from_forgery + skip_before_action :verify_authenticity_token + end + + def create + if valid_signature?(request.headers["X-Signature"]) + queue_event(verify_params.as_json) + head :ok + else + head :bad_request + end + rescue Pay::LemonSqueezy::Error + head :bad_request + end + + private + + def queue_event(event) + return unless Pay::Webhooks.delegator.listening?("lemon_squeezy.#{params[:meta][:event_name]}") + + record = Pay::Webhook.create!(processor: :lemon_squeezy, event_type: params[:meta][:event_name], event: event) + Pay::Webhooks::ProcessJob.perform_later(record) + end + + # Pass Lemon Squeezy signature from request.headers["X-Signature"] + def valid_signature?(signature) + return false if signature.blank? + + key = Pay::LemonSqueezy.signing_secret + data = request.raw_post + digest = OpenSSL::Digest.new("sha256") + + hmac = OpenSSL::HMAC.hexdigest(digest, key, data) + ActiveSupport::SecurityUtils.secure_compare(hmac, signature) + end + + def verify_params + params.except(:action, :controller).permit! + end + end + end +end diff --git a/app/models/pay/charge.rb b/app/models/pay/charge.rb index 7eb1f8d6..e2db89f5 100644 --- a/app/models/pay/charge.rb +++ b/app/models/pay/charge.rb @@ -44,7 +44,7 @@ class Charge < Pay::ApplicationRecord store_accessor :data, :refunds # array of refunds # Helpers for payment processors - %w[braintree stripe paddle_billing paddle_classic fake_processor].each do |processor_name| + %w[braintree stripe paddle_billing paddle_classic lemon_squeezy fake_processor].each do |processor_name| define_method :"#{processor_name}?" do customer.processor == processor_name end diff --git a/app/models/pay/customer.rb b/app/models/pay/customer.rb index f9b94ebc..977707a9 100644 --- a/app/models/pay/customer.rb +++ b/app/models/pay/customer.rb @@ -25,7 +25,7 @@ class Customer < Pay::ApplicationRecord delegate :email, to: :owner delegate_missing_to :pay_processor - %w[stripe braintree paddle_billing paddle_classic fake_processor].each do |processor_name| + %w[stripe braintree paddle_billing paddle_classic lemon_squeezy fake_processor].each do |processor_name| scope processor_name, -> { where(processor: processor_name) } define_method :"#{processor_name}?" do diff --git a/app/models/pay/subscription.rb b/app/models/pay/subscription.rb index 567a3168..bc10bff5 100644 --- a/app/models/pay/subscription.rb +++ b/app/models/pay/subscription.rb @@ -43,7 +43,7 @@ class Subscription < Pay::ApplicationRecord delegate_missing_to :payment_processor # Helper methods for payment processors - %w[braintree stripe paddle_billing paddle_classic fake_processor].each do |processor_name| + %w[braintree stripe paddle_billing paddle_classic lemon_squeezy fake_processor].each do |processor_name| define_method :"#{processor_name}?" do customer.processor == processor_name end diff --git a/app/models/pay/webhook.rb b/app/models/pay/webhook.rb index f66f15ab..036ee96b 100644 --- a/app/models/pay/webhook.rb +++ b/app/models/pay/webhook.rb @@ -8,7 +8,7 @@ def process! Pay::Webhooks.instrument type: "#{processor}.#{event_type}", event: rehydrated_event # Remove after successfully processing - destroy + # destroy end # Events have already been verified by the webhook, so we just store the raw data @@ -21,6 +21,8 @@ def rehydrated_event to_recursive_ostruct(event["data"]) when "paddle_classic" to_recursive_ostruct(event) + when "lemon_squeezy" + to_recursive_ostruct(event) when "stripe" ::Stripe::Event.construct_from(event) else diff --git a/config/routes.rb b/config/routes.rb index 041102ef..aa1c5593 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,4 +6,5 @@ post "webhooks/braintree", to: "pay/webhooks/braintree#create" if Pay::Braintree.enabled? post "webhooks/paddle_billing", to: "pay/webhooks/paddle_billing#create" if Pay::PaddleBilling.enabled? post "webhooks/paddle_classic", to: "pay/webhooks/paddle_classic#create" if Pay::PaddleClassic.enabled? + post "webhooks/lemon_squeezy", to: "pay/webhooks/lemon_squeezy#create" if Pay::LemonSqueezy.enabled? end diff --git a/docs/1_installation.md b/docs/1_installation.md index 16845283..a5426aa7 100644 --- a/docs/1_installation.md +++ b/docs/1_installation.md @@ -18,6 +18,9 @@ gem "braintree", "~> 4.7" # To use Paddle Billing or Paddle Classic, also include: gem "paddle", "~> 2.5" +# To use Lemon Squeezy, also include: +gem "lemonsqueezy", "~> 1.0" + # To use Receipts gem for creating invoice and receipt PDFs, also include: gem "receipts", "~> 2.0" ``` diff --git a/docs/2_configuration.md b/docs/2_configuration.md index e17f821f..a0bc6e11 100644 --- a/docs/2_configuration.md +++ b/docs/2_configuration.md @@ -39,6 +39,10 @@ paddle_classic: vendor_auth_code: yyyy public_key_base64: MII...== environment: sandbox +lemon_squeezy: + api_key: xxxx + store_id: yyyy + signing_secret: aaaa ``` You can also nest these credentials under the Rails environment if using a shared credentials file. @@ -72,6 +76,9 @@ Pay will also check environment variables for API keys: * `PADDLE_CLASSIC_PUBLIC_KEY_FILE` * `PADDLE_CLASSIC_PUBLIC_KEY_BASE64` * `PADDLE_CLASSIC_ENVIRONMENT` +* `LEMON_SQUEEZY_API_KEY` +* `LEMON_SQUEEZY_STORE_ID` +* `LEMON_SQUEEZY_SIGNING_SECRET` > [!TIP] > @@ -131,7 +138,7 @@ Pay.setup do |config| config.automount_routes = true config.routes_path = "/pay" # Only when automount_routes is true # All processors are enabled by default. If a processor is already implemented in your application, you can omit it from this list and the processor will not be set up through the Pay gem. - config.enabled_processors = [:stripe, :braintree, :paddle_billing, :paddle_classic] + config.enabled_processors = [:stripe, :braintree, :paddle_billing, :paddle_classic, :lemon_squeezy] # To disable all emails, set the following configuration option to false: config.send_emails = true diff --git a/docs/3_customers.md b/docs/3_customers.md index 85ad7e2f..bd730b31 100644 --- a/docs/3_customers.md +++ b/docs/3_customers.md @@ -11,6 +11,7 @@ Before you can process payments, you need to assign a payment processor for the @user.set_payment_processor :braintree @user.set_payment_processor :paddle_billing @user.set_payment_processor :paddle_classic +@user.set_payment_processor :lemon_squeezy @user.set_payment_processor :fake_processor, allow_fake: true ``` @@ -50,11 +51,16 @@ Only one `Pay::Customer` can be the default which is used for `payment_processor ## Retrieving a Customer object from the Payment Processor -If you need to access the API object directly from the payment processor like the `Stripe::Customer`. You can retrieve the object with: +For Paddle Billing and Lemon Squeezy, using the `customer` method will create a new customer on the payment processor. + +If the `processor_id` is already set, it will retrieve the customer from the payment processor and return the object +directly from the API. Like so: ```ruby @user.payment_processor.customer #=> # +#=> # +#=> # ``` ##### Paddle Classic: diff --git a/docs/4_payment_methods.md b/docs/4_payment_methods.md index fb995ac7..3dd5ef33 100644 --- a/docs/4_payment_methods.md +++ b/docs/4_payment_methods.md @@ -64,6 +64,25 @@ Paddle uses an [Update Payment Details URL](https://developer.paddle.com/guides/ You may either redirect to this URL or use Paddle's Javascript to render as an overlay or inline. +##### Lemon Squeezy + +Much like Paddle, Lemon Squeezy uses an Update Payment Details URL for each customer which allows them to update +the payment method. This URL expires after 24 hours, so this method retrieves a new one from the API each time. + +```ruby +@user.payment_processor.subscription.update_url +``` + +Lemon Squeezy also offer a [Customer Portal](https://www.lemonsqueezy.com/features/customer-portal) where customers +can manage their subscriptions and payment methods. You can link to this portal using the `portal_url` method. +Just like the Update URL, this URL expires after 24 hours, so this method retrieves a new one from the API each time. + +```ruby +@user.payment_processor.subscription.portal_url +``` + +You may either redirect to this URL or use Paddle's Javascript to render as an overlay or inline. + ## Adding other Payment Methods You can also add a payment method without making it the default. diff --git a/docs/5_charges.md b/docs/5_charges.md index 48b189b3..a0cf180e 100644 --- a/docs/5_charges.md +++ b/docs/5_charges.md @@ -52,6 +52,10 @@ Paddle Classic requires an active subscription on the customer in order to creat @user.payment_processor.charge(1500, {charge_name: "Test"}) # $15.00 USD ``` +##### Lemon Squeezy Charges + +Lemon Squeezy currently doesn't support one-time charges. + ## Retrieving Charges To see a list of charges for a customer, you can access them with: diff --git a/docs/6_subscriptions.md b/docs/6_subscriptions.md index cd782a8c..7a66a42d 100644 --- a/docs/6_subscriptions.md +++ b/docs/6_subscriptions.md @@ -115,6 +115,48 @@ Or with Paddle Button Checkout: ``` +##### Lemon Squeezy Subscriptions + +Lemon Squeezy does not allow you to create a subscription through the API. Instead, Pay uses webhooks to create the +subscription in the database. + +Lemon Squeezy offer 2 checkout flows, a hosted checkout and a checkout overlay. When creating a Product in the +Lemon Squeezy dashboard, clicking the "Share" button will provide you with the URLs for either checkout flow. + +For example, the hosted checkout flow: + +```html +https://STORE.lemonsqueezy.com/checkout/buy/UUID +``` + +And the checkout overlay flow: + +```html +Buy A Product + +``` + +It's currently not possible to pass a pre-existing Customer ID to Lemon Squeezy, so you can use the passthrough +method to associate the subscription with the correct `Pay::Customer`. + +You can pass additional options to the checkout session. You can view the [supported fields here](https://docs.lemonsqueezy.com/help/checkout/prefilling-checkout-fields) +and the [custom data field here](https://docs.lemonsqueezy.com/help/checkout/passing-custom-data). + +###### Lemon Squeezy Passthrough Helper + +You can use the `Pay::LemonSqueezy.passthrough` helper to generate the `checkout[custom][passthrough]` field. + +You'll need to replace `storename` with your store URL slug & `UUID` with the UUID of the plan you want to use, which +can be found by clicking Share on the product in Lemon Squeezy's dashboard. + +```html + + Sign up to Plan + +``` + ## Retrieving a Subscription from the Database ```ruby diff --git a/docs/7_webhooks.md b/docs/7_webhooks.md index d53b3c9e..d39b9331 100644 --- a/docs/7_webhooks.md +++ b/docs/7_webhooks.md @@ -22,6 +22,7 @@ To configure webhooks on your payment processor, use the following URLs while re * **Braintree** - `https://example.org/pay/webhooks/braintree` * **Paddle Billing** - `https://example.org/pay/webhooks/paddle_billing` * **Paddle Classic** - `https://example.org/pay/webhooks/paddle_classic` +* **Lemon Squeezy** - `https://example.org/pay/webhooks/lemon_squeezy` #### Mount path @@ -53,6 +54,7 @@ Since we support multiple payment providers, each event type is prefixed with th "braintree.subscription_charged_successfully" "paddle_billing.subscription.created" "paddle_classic.subscription_created" +"lemon_squeezy.subscription_created" ``` ## Custom Webhook Listeners diff --git a/docs/lemon_squeezy/1_overview.md b/docs/lemon_squeezy/1_overview.md new file mode 100644 index 00000000..c04fec54 --- /dev/null +++ b/docs/lemon_squeezy/1_overview.md @@ -0,0 +1,50 @@ +# Using Pay with Lemon Squeezy + +Lemon Squeezy works differently than most of the other payment processors so it comes with some limitations and differences. + +* Checkout only happens via iFrame or hosted page +* Cancelling a subscription cannot be resumed + +## Creating Customers + +You can create a customer, which subscriptions belong to. + +```ruby +# Set the payment processor +@user.set_payment_processor :lemon_squeezy + +# Create the customer on Lemon Squeezy +@user.payment_processor.customer +``` + +## Subscriptions + +Lemon Squeezy subscriptions are not created through the API, but through Webhooks. When a +subscription is created, Lemon Squeezy will send a webhook to your application. Pay will +automatically create the subscription for you. + +## Configuration + +### API Key + +You can generate an API key [here](https://app.lemonsqueezy.com/settings/api) + +### Store ID + +Certain API calls require a Store ID. You can find this [here](https://app.lemonsqueezy.com/settings/stores). + +### Signing Secret + +When creating a webhook, you have the option to set a signing secret. This is used to verify +that the webhook request is coming from Lemon Squeezy. + +You'll find this page [here](https://app.lemonsqueezy.com/settings/webhooks). + +### Environment Variables + +Pay will automatically look for the following environment variables, or the equivalent +Rails credentials: + +* `LEMON_SQUEEZY_API_KEY` +* `LEMON_SQUEEZY_STORE_ID` +* `LEMON_SQUEEZY_SIGNING_SECRET` diff --git a/docs/lemon_squeezy/2_javascript.md b/docs/lemon_squeezy/2_javascript.md new file mode 100644 index 00000000..ded5314e --- /dev/null +++ b/docs/lemon_squeezy/2_javascript.md @@ -0,0 +1,43 @@ +# Lemon Squeezy Javascript + +Lemon.js is used for Lemon Squeezy. It is a Javascript library that allows you to embed +a checkout into your website. + +## Setup + +Add the Lemon.js script in your application layout. + +```html + +``` + +## Generating a Checkout Button + +With Lemon.js initialized, it will automatically look for any elements with the `lemonsqueezy-button` +class and turn them into a checkout button. + +It doesn't support sending attributes, so to customize the checkout button and session, you'll need to +add additional parameters to the URL. You can view the [supported fields here](https://docs.lemonsqueezy.com/help/checkout/prefilling-checkout-fields) and the [custom data field here](https://docs.lemonsqueezy.com/help/checkout/passing-custom-data). + +You can use the `Pay::LemonSqueezy.passthrough` helper to generate the `checkout[custom][passthrough]` field. + +You'll need to replace `storename` with your store URL slug & `UUID` with the UUID of the plan you want to use, which +can be found by clicking Share on the product in Lemon Squeezy's dashboard. + +```html + + Sign up to Plan + +``` + +## Hosted Checkout + +Hosted checkout is the default checkout method. It will open a new window to the Lemon Squeezy website. +If Lemon.js is loaded, and the `lemonsqueezy-button` class is added to the link, it will open the checkout +in an overlay. + +## Overlay Checkout + +To enable overlay checkout, add `embed=1` to the above URL. diff --git a/docs/lemon_squeezy/3_webhooks.md b/docs/lemon_squeezy/3_webhooks.md new file mode 100644 index 00000000..b474071f --- /dev/null +++ b/docs/lemon_squeezy/3_webhooks.md @@ -0,0 +1,15 @@ +# Lemon Squeezy Webhooks + +## Endpoint + +The webhook endpoint for Lemon Squeezy is `/pay/webhooks/lemon_squeezy` by default. + +## Events + +Pay requires the following webhooks to properly sync charges and subscriptions as they happen. + +```ruby +subscription_created +subscription_updated +subscription_payment_success +``` diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile index 578bf6cd..bc9b8902 100644 --- a/gemfiles/rails_6_1.gemfile +++ b/gemfiles/rails_6_1.gemfile @@ -10,6 +10,7 @@ gem "standard" gem "vcr" gem "webmock" gem "braintree", ">= 2.92.0" +gem "lemonsqueezy", "~> 1.0" gem "paddle", "~> 2.4" gem "stripe", "~> 12.0" gem "prawn" diff --git a/gemfiles/rails_6_1.gemfile.lock b/gemfiles/rails_6_1.gemfile.lock index b316eceb..172b451f 100644 --- a/gemfiles/rails_6_1.gemfile.lock +++ b/gemfiles/rails_6_1.gemfile.lock @@ -110,6 +110,8 @@ GEM iniparse (1.5.0) json (2.7.2) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) logger (1.6.0) loofah (2.22.0) @@ -293,6 +295,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap diff --git a/gemfiles/rails_7.gemfile b/gemfiles/rails_7.gemfile index 6fbce0fa..14159728 100644 --- a/gemfiles/rails_7.gemfile +++ b/gemfiles/rails_7.gemfile @@ -10,6 +10,7 @@ gem "standard" gem "vcr" gem "webmock" gem "braintree", ">= 2.92.0" +gem "lemonsqueezy", "~> 1.0" gem "paddle", "~> 2.4" gem "stripe", "~> 12.0" gem "prawn" diff --git a/gemfiles/rails_7.gemfile.lock b/gemfiles/rails_7.gemfile.lock index 0db132f5..0038ed47 100644 --- a/gemfiles/rails_7.gemfile.lock +++ b/gemfiles/rails_7.gemfile.lock @@ -116,6 +116,8 @@ GEM iniparse (1.5.0) json (2.7.2) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) logger (1.6.0) loofah (2.22.0) @@ -299,6 +301,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile index ebba8fe7..9e1ce050 100644 --- a/gemfiles/rails_7_1.gemfile +++ b/gemfiles/rails_7_1.gemfile @@ -10,6 +10,7 @@ gem "standard" gem "vcr" gem "webmock" gem "braintree", ">= 2.92.0" +gem "lemonsqueezy", "~> 1.0" gem "paddle", "~> 2.4" gem "stripe", "~> 12.0" gem "prawn" diff --git a/gemfiles/rails_7_1.gemfile.lock b/gemfiles/rails_7_1.gemfile.lock index a53cabc9..cce6aa62 100644 --- a/gemfiles/rails_7_1.gemfile.lock +++ b/gemfiles/rails_7_1.gemfile.lock @@ -132,6 +132,8 @@ GEM reline (>= 0.4.2) json (2.7.2) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) logger (1.6.0) loofah (2.22.0) @@ -326,6 +328,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index ef34339e..bfc8f6b2 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -10,6 +10,7 @@ gem "standard" gem "vcr" gem "webmock" gem "braintree", ">= 2.92.0" +gem "lemonsqueezy", "~> 1.0" gem "paddle", "~> 2.4" gem "stripe", "~> 12.0" gem "prawn" diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index 4dbf0f3d..24e81b52 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -156,6 +156,8 @@ GEM reline (>= 0.4.2) json (2.7.2) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.1) + faraday (~> 2.0) lint_roller (1.1.0) logger (1.6.0) loofah (2.22.0) @@ -332,6 +334,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap diff --git a/lib/pay.rb b/lib/pay.rb index 15631fa9..fcf529c2 100644 --- a/lib/pay.rb +++ b/lib/pay.rb @@ -19,6 +19,7 @@ module Pay autoload :FakeProcessor, "pay/fake_processor" autoload :PaddleBilling, "pay/paddle_billing" autoload :PaddleClassic, "pay/paddle_classic" + autoload :LemonSqueezy, "pay/lemon_squeezy" autoload :Stripe, "pay/stripe" autoload :Webhooks, "pay/webhooks" @@ -56,7 +57,7 @@ def self.support_email=(value) @@routes_path = "/pay" mattr_accessor :enabled_processors - @@enabled_processors = [:stripe, :braintree, :paddle_billing, :paddle_classic] + @@enabled_processors = [:stripe, :braintree, :paddle_billing, :paddle_classic, :lemon_squeezy] mattr_accessor :send_emails @@send_emails = true diff --git a/lib/pay/engine.rb b/lib/pay/engine.rb index 5a191cca..b9e3d0ae 100644 --- a/lib/pay/engine.rb +++ b/lib/pay/engine.rb @@ -28,12 +28,14 @@ class Engine < ::Rails::Engine Pay::Braintree.configure_webhooks if Pay::Braintree.enabled? Pay::PaddleBilling.configure_webhooks if Pay::PaddleBilling.enabled? Pay::PaddleClassic.configure_webhooks if Pay::PaddleClassic.enabled? + Pay::LemonSqueezy.configure_webhooks if Pay::LemonSqueezy.enabled? end config.to_prepare do Pay::Stripe.setup if Pay::Stripe.enabled? Pay::Braintree.setup if Pay::Braintree.enabled? Pay::PaddleBilling.setup if Pay::PaddleBilling.enabled? + Pay::LemonSqueezy.setup if Pay::LemonSqueezy.enabled? if defined?(::Receipts::VERSION) if Pay::Engine.version_matches?(required: "~> 2", current: ::Receipts::VERSION) diff --git a/lib/pay/lemon_squeezy.rb b/lib/pay/lemon_squeezy.rb new file mode 100644 index 00000000..bdd50cd5 --- /dev/null +++ b/lib/pay/lemon_squeezy.rb @@ -0,0 +1,56 @@ +module Pay + module LemonSqueezy + autoload :Billable, "pay/lemon_squeezy/billable" + autoload :Charge, "pay/lemon_squeezy/charge" + autoload :Error, "pay/lemon_squeezy/error" + autoload :PaymentMethod, "pay/lemon_squeezy/payment_method" + autoload :Subscription, "pay/lemon_squeezy/subscription" + + module Webhooks + autoload :Subscription, "pay/lemon_squeezy/webhooks/subscription" + autoload :Payment, "pay/lemon_squeezy/webhooks/payment" + end + + extend Env + + def self.enabled? + return false unless Pay.enabled_processors.include?(:lemon_squeezy) && defined?(::LemonSqueezy) + + Pay::Engine.version_matches?(required: "~> 1.0", current: ::LemonSqueezy::VERSION) || (raise "[Pay] lemonsqueezy gem must be version ~> 1.0") + end + + def self.setup + ::LemonSqueezy.config.api_key = api_key + end + + def self.api_key + find_value_by_name(:lemon_squeezy, :api_key) + end + + def self.store_id + find_value_by_name(:lemon_squeezy, :store_id) + end + + def self.signing_secret + find_value_by_name(:lemon_squeezy, :signing_secret) + end + + def self.passthrough(owner:, **options) + owner.to_sgid.to_s + end + + def self.owner_from_passthrough(passthrough) + GlobalID::Locator.locate_signed passthrough + rescue JSON::ParserError + nil + end + + def self.configure_webhooks + Pay::Webhooks.configure do |events| + events.subscribe "lemon_squeezy.subscription_payment_success", Pay::LemonSqueezy::Webhooks::Payment.new + events.subscribe "lemon_squeezy.subscription_created", Pay::LemonSqueezy::Webhooks::Subscription.new + events.subscribe "lemon_squeezy.subscription_updated", Pay::LemonSqueezy::Webhooks::Subscription.new + end + end + end +end diff --git a/lib/pay/lemon_squeezy/billable.rb b/lib/pay/lemon_squeezy/billable.rb new file mode 100644 index 00000000..4642d928 --- /dev/null +++ b/lib/pay/lemon_squeezy/billable.rb @@ -0,0 +1,71 @@ +module Pay + module LemonSqueezy + class Billable + attr_reader :pay_customer + + delegate :processor_id, + :processor_id?, + :email, + :customer_name, + :card_token, + to: :pay_customer + + def initialize(pay_customer) + @pay_customer = pay_customer + end + + def customer_attributes + {email: email, name: customer_name} + end + + # Retrieves a LemonSqueezy::Customer object + # + # Finds an existing LemonSqueezy::Customer if processor_id exists + # Creates a new LemonSqueezy::Customer using `email` and `customer_name` if empty processor_id + # + # Returns a LemonSqueezy::Customer object + def customer + if processor_id? + ::LemonSqueezy::Customer.retrieve(id: processor_id) + else + sc = ::LemonSqueezy::Customer.create(store_id: Pay::LemonSqueezy.store_id, email: email, name: customer_name) + pay_customer.update!(processor_id: sc.id) + sc + end + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + + # Syncs name and email to LemonSqueezy::Customer + # You can also pass in other attributes that will be merged into the default attributes + def update_customer!(**attributes) + customer unless processor_id? + attrs = customer_attributes.merge(attributes) + ::LemonSqueezy::Customer.update(id: processor_id, **attrs) + end + + def charge(amount, options = {}) + raise Pay::Error, "LemonSqueezy does not support one-off charges" + end + + def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options) + # pass + end + + def add_payment_method(token = nil, default: true) + # pass + end + + def trial_end_date(subscription) + return unless subscription.state == "on-trial" + Time.zone.parse(subscription.renews_at).end_of_day + end + + def processor_subscription(subscription_id, options = {}) + ::LemonSqueezy::Subscription.retrieve(id: subscription_id) + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + end + end +end diff --git a/lib/pay/lemon_squeezy/charge.rb b/lib/pay/lemon_squeezy/charge.rb new file mode 100644 index 00000000..0924f204 --- /dev/null +++ b/lib/pay/lemon_squeezy/charge.rb @@ -0,0 +1,50 @@ +module Pay + module LemonSqueezy + class Charge + attr_reader :pay_charge + + delegate :processor_id, :customer, to: :pay_charge + + def initialize(pay_charge) + @pay_charge = pay_charge + end + + def self.sync(charge_id, object: nil, try: 0, retries: 1) + # Skip loading the latest subscription invoice details from the API if we already have it + object ||= ::LemonSqueezy::SubscriptionInvoice.retrieve(id: charge_id) + + attrs = object.data.attributes if object.respond_to?(:data) + attrs ||= object + + # Ignore charges without a Customer + return if attrs.customer_id.blank? + + pay_customer = Pay::Customer.find_by(processor: :lemon_squeezy, processor_id: attrs.customer_id) + return unless pay_customer + + attributes = { + amount: attrs.total, + created_at: attrs.created_at, + currency: attrs.currency, + subscription: pay_customer.subscriptions.find_by(processor_id: attrs.subscription_id), + payment_method_type: "card", + brand: attrs.card_brand, + last4: attrs.card_last_four + } + + # Update customer's payment method + Pay::LemonSqueezy::PaymentMethod.sync(pay_customer: pay_customer, attributes: attrs) + + # Update or create the charge + if (pay_charge = pay_customer.charges.find_by(processor_id: charge_id)) + pay_charge.with_lock do + pay_charge.update!(attributes) + end + pay_charge + else + pay_customer.charges.create!(attributes.merge(processor_id: charge_id)) + end + end + end + end +end diff --git a/lib/pay/lemon_squeezy/error.rb b/lib/pay/lemon_squeezy/error.rb new file mode 100644 index 00000000..5b282f5b --- /dev/null +++ b/lib/pay/lemon_squeezy/error.rb @@ -0,0 +1,7 @@ +module Pay + module LemonSqueezy + class Error < Pay::Error + delegate :message, to: :cause + end + end +end diff --git a/lib/pay/lemon_squeezy/payment_method.rb b/lib/pay/lemon_squeezy/payment_method.rb new file mode 100644 index 00000000..57c5b986 --- /dev/null +++ b/lib/pay/lemon_squeezy/payment_method.rb @@ -0,0 +1,37 @@ +module Pay + module LemonSqueezy + class PaymentMethod + attr_reader :pay_payment_method + + delegate :customer, :processor_id, to: :pay_payment_method + + def self.sync(pay_customer:, attributes:) + return unless pay_customer.subscription + + payment_method = pay_customer.default_payment_method || pay_customer.build_default_payment_method + payment_method.processor_id ||= NanoId.generate + + attrs = { + type: "card", + brand: attributes.card_brand, + last4: attributes.card_last_four + } + + payment_method.update!(attrs) + payment_method + end + + def initialize(pay_payment_method) + @pay_payment_method = pay_payment_method + end + + # Sets payment method as default + def make_default! + end + + # Remove payment method + def detach + end + end + end +end diff --git a/lib/pay/lemon_squeezy/subscription.rb b/lib/pay/lemon_squeezy/subscription.rb new file mode 100644 index 00000000..15ca78e4 --- /dev/null +++ b/lib/pay/lemon_squeezy/subscription.rb @@ -0,0 +1,176 @@ +module Pay + module LemonSqueezy + class Subscription + attr_reader :pay_subscription + + delegate :active?, + :canceled?, + :on_grace_period?, + :on_trial?, + :ends_at, + :name, + :owner, + :pause_starts_at, + :pause_starts_at?, + :processor_id, + :processor_plan, + :processor_subscription, + :prorate, + :prorate?, + :quantity, + :quantity?, + :trial_ends_at, + to: :pay_subscription + + def self.sync(subscription_id, object: nil, name: Pay.default_product_name) + # Passthrough is not return from this API, so we can't use that + object ||= ::LemonSqueezy::Subscription.retrieve(id: subscription_id) + + attrs = object.data.attributes if object.respond_to?(:data) + attrs ||= object + + pay_customer = Pay::Customer.find_by(processor: :lemon_squeezy, processor_id: attrs.customer_id) + + # If passthrough exists (only on webhooks) we can use it to create the Pay::Customer + if pay_customer.nil? && object.meta.custom_data && object.meta.custom_data.passthrough + owner = Pay::LemonSqueezy.owner_from_passthrough(object.meta.custom_data.passthrough) + pay_customer = owner&.set_payment_processor(:lemon_squeezy, processor_id: attrs.customer_id) + end + + return unless pay_customer + + attributes = { + current_period_end: attrs.renews_at, + current_period_start: attrs.created_at, + ends_at: (attrs.ends_at ? Time.parse(attrs.ends_at) : nil), + pause_starts_at: (attrs.pause&.resumes_at ? Time.parse(attrs.pause.resumes_at) : nil), + status: attrs.status + } + + attributes[:processor_plan] = attrs.first_subscription_item.price_id + attributes[:quantity] = attrs.first_subscription_item.quantity + + case attributes[:status] + when "cancelled" + # Remove payment methods since customer cannot be reused after cancelling + Pay::PaymentMethod.where(customer_id: attrs.customer_id).destroy_all + when "on_trial" + attributes[:trial_ends_at] = Time.parse(attrs.trial_ends_at) + when "paused" + # attributes[:pause_starts_at] = Time.parse(object.paused_at) + when "active", "past_due" + attributes[:trial_ends_at] = nil + attributes[:pause_starts_at] = nil + attributes[:ends_at] = nil + end + + # Update or create the subscription + if (pay_subscription = pay_customer.subscriptions.find_by(processor_id: subscription_id)) + pay_subscription.with_lock do + pay_subscription.update!(attributes) + end + pay_subscription + else + pay_customer.subscriptions.create!(attributes.merge(name: name, processor_id: subscription_id)) + end + end + + def initialize(pay_subscription) + @pay_subscription = pay_subscription + end + + def subscription(**options) + @lemon_squeezy_subscription ||= ::LemonSqueezy::Subscription.retrieve(id: processor_id) + end + + def portal_url + sub = ::LemonSqueezy::Subscription.retrieve(id: pay_subscription.processor_id) + sub.urls.customer_portal + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + + def update_url + sub = ::LemonSqueezy::Subscription.retrieve(id: pay_subscription.processor_id) + sub.urls.update_payment_method + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + + def cancel(**options) + return if canceled? + response = ::LemonSqueezy::Subscription.cancel(id: processor_id) + pay_subscription.update(status: response.status, ends_at: response.ends_at) + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + + def cancel_now!(**options) + # Lemon Squeezy doesn't support cancelling immediately + end + + def change_quantity(quantity, **options) + items = [{ + price_id: processor_plan, + quantity: quantity + }] + + ::Paddle::Subscription.update(id: processor_id, items: items, proration_billing_mode: "prorated_immediately") + rescue ::Paddle::Error => e + raise Pay::LemonSqueezy::Error, e + end + + # A subscription could be set to cancel or pause in the future + # It is considered on grace period until the cancel or pause time begins + def on_grace_period? + (canceled? && Time.current < ends_at) || (paused? && pause_starts_at? && Time.current < pause_starts_at) + end + + def paused? + pay_subscription.status == "paused" + end + + def pause(**options) + response = ::LemonSqueezy::Subscription.pause(id: processor_id, **options) + pay_subscription.update!(status: :paused, pause_starts_at: response.pause&.resumes_at) + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + + def resumable? + paused? || canceled? + end + + def resume + unless resumable? + raise StandardError, "You can only resume paused or cancelled subscriptions" + end + + if paused? && pause_starts_at? && Time.current < pause_starts_at + ::LemonSqueezy::Subscription.unpause(id: processor_id) + else + ::LemonSqueezy::Subscription.uncancel(id: processor_id) + end + + pay_subscription.update(status: :active, pause_starts_at: nil) + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + + # Lemon Squeezy requires both the Product ID and Variant ID. + # The Variant ID will be saved as the processor_plan + def swap(plan, **options) + raise StandardError, "A plan_id is required to swap a subscription" unless plan + raise StandardError, "A variant_id is required to swap a subscription" unless options[:variant_id] + + ::LemonSqueezy::Subscription.change_plan id: processor_id, plan_id: plan, variant_id: options[:variant_id] + + pay_subscription.update(processor_plan: options[:variant_id], ends_at: nil, status: :active) + end + + # Retries the latest invoice for a Past Due subscription + def retry_failed_payment + end + end + end +end diff --git a/lib/pay/lemon_squeezy/webhooks/payment.rb b/lib/pay/lemon_squeezy/webhooks/payment.rb new file mode 100644 index 00000000..7d8ef081 --- /dev/null +++ b/lib/pay/lemon_squeezy/webhooks/payment.rb @@ -0,0 +1,11 @@ +module Pay + module LemonSqueezy + module Webhooks + class Payment + def call(event) + Pay::LemonSqueezy::Charge.sync(event.data.id) + end + end + end + end +end diff --git a/lib/pay/lemon_squeezy/webhooks/subscription.rb b/lib/pay/lemon_squeezy/webhooks/subscription.rb new file mode 100644 index 00000000..14f9105a --- /dev/null +++ b/lib/pay/lemon_squeezy/webhooks/subscription.rb @@ -0,0 +1,11 @@ +module Pay + module LemonSqueezy + module Webhooks + class Subscription + def call(event) + Pay::LemonSqueezy::Subscription.sync(event.data.id, object: event) + end + end + end + end +end diff --git a/test/fixtures/pay/customers.yml b/test/fixtures/pay/customers.yml index 66e7d9bf..3a9eb0e5 100644 --- a/test/fixtures/pay/customers.yml +++ b/test/fixtures/pay/customers.yml @@ -24,6 +24,12 @@ paddle_classic: processor_id: 17368056 default: true +lemon_squeezy: + owner: lemon_squeezy (User) + processor: lemon_squeezy + processor_id: 2194219 + default: true + fake: owner: fake (User) processor: fake_processor diff --git a/test/fixtures/pay/subscriptions.yml b/test/fixtures/pay/subscriptions.yml index a8c2e9a5..c1b4133a 100644 --- a/test/fixtures/pay/subscriptions.yml +++ b/test/fixtures/pay/subscriptions.yml @@ -31,6 +31,14 @@ paddle_classic: quantity: 1 status: active +lemon_squeezy: + customer: lemon_squeezy + name: default + processor_id: 253735 + processor_plan: 174873 + quantity: 1 + status: active + fake: customer: fake name: default diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index e3cc48f3..6afca730 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -28,6 +28,12 @@ paddle_classic: first_name: Paddle Classic last_name: User +# User with lemon squeezy payment processor +lemon_squeezy: + email: lemon-squeezy@example.org + first_name: Lemon Squeezy + last_name: User + # User with fake_processor payment processor fake: email: fake@example.org diff --git a/test/pay/lemon_squeezy/billable_test.rb b/test/pay/lemon_squeezy/billable_test.rb new file mode 100644 index 00000000..1c66ec10 --- /dev/null +++ b/test/pay/lemon_squeezy/billable_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class Pay::LemonSqueezy::Billable::Test < ActiveSupport::TestCase + setup do + @pay_customer = pay_customers(:lemon_squeezy) + end + + test "lemon squeezy cannot create a charge" do + assert_raises(Pay::Error) { @pay_customer.charge(1000) } + end + + test "retrieving a lemon squeezy subscription" do + subscription = ::LemonSqueezy::Subscription.retrieve(id: "253735") + assert_equal @pay_customer.processor_subscription("253735").id, subscription.id + end +end diff --git a/test/pay/lemon_squeezy/error_test.rb b/test/pay/lemon_squeezy/error_test.rb new file mode 100644 index 00000000..63ffac8c --- /dev/null +++ b/test/pay/lemon_squeezy/error_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class Pay::LemonSqueezy::ErrorTest < ActiveSupport::TestCase + test "re-raised lemon squeezy exceptions keep the same message" do + exception = assert_raises { + begin + raise ::LemonSqueezy::Error, "The connection failed" + rescue + raise ::Pay::LemonSqueezy::Error + end + } + + assert_equal "The connection failed", exception.message + assert_equal ::LemonSqueezy::Error, exception.cause.class + end +end diff --git a/test/pay/lemon_squeezy/subscription_test.rb b/test/pay/lemon_squeezy/subscription_test.rb new file mode 100644 index 00000000..f5c0c206 --- /dev/null +++ b/test/pay/lemon_squeezy/subscription_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class Pay::LemonSqueezy::Subscription::Test < ActiveSupport::TestCase + setup do + @pay_customer = pay_customers(:lemon_squeezy) + end + + test "lemon squeezy processor subscription" do + assert_equal @pay_customer.subscription.processor_subscription.class, ::LemonSqueezy::Subscription + assert_equal "active", @pay_customer.subscription.status + end + + test "lemon squeezy paused subscription is not active" do + @pay_customer.subscription.update!(status: :paused) + refute @pay_customer.subscription.active? + end + + test "lemon squeezy paused subscription is paused" do + @pay_customer.subscription.update!(status: :paused) + assert @pay_customer.subscription.paused? + end + + test "lemon squeezy paused subscription is not canceled" do + @pay_customer.subscription.update!(status: :paused) + assert_not @pay_customer.subscription.canceled? + end + + test "lemon squeezy can swap plans" do + @pay_customer.subscription.swap("174873", variant_id: "225676") + assert_equal 225676, @pay_customer.subscription.processor_subscription.variant_id + assert_equal "active", @pay_customer.subscription.status + end +end diff --git a/test/support/vcr.rb b/test/support/vcr.rb index b0b3df19..3d7c10b7 100644 --- a/test/support/vcr.rb +++ b/test/support/vcr.rb @@ -13,6 +13,7 @@ c.filter_sensitive_data("") { Pay::Braintree.private_key } c.filter_sensitive_data("") { Pay::PaddleClassic.vendor_auth_code } c.filter_sensitive_data("") { Pay::PaddleBilling.api_key } + c.filter_sensitive_data("") { Pay::LemonSqueezy.api_key } end class ActiveSupport::TestCase diff --git a/test/vcr_cassettes/test_lemon_squeezy_can_swap_plans.yml b/test/vcr_cassettes/test_lemon_squeezy_can_swap_plans.yml new file mode 100644 index 00000000..6a5a1eac --- /dev/null +++ b/test/vcr_cassettes/test_lemon_squeezy_can_swap_plans.yml @@ -0,0 +1,117 @@ +--- +http_interactions: +- request: + method: patch + uri: https://api.lemonsqueezy.com/v/subscriptions/253735 + body: + encoding: UTF-8 + string: '{"data":{"type":"subscriptions","id":"253735","attributes":{"product_id":"74873","variant_id":"225676"}}}' + headers: + User-Agent: + - lemonsqueezy/v.0.0 (github.com/deanpcmad/lemonsqueezy) + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + Authorization: + - Bearer eyJ0eXAiOiJKVQiLCJhbGciOiJSUzINiJ9.eyJhdWQiOiI5NGQOWNlZikYmI4LTRlYTUtYjE3OCkMjU0MGZjZDY5MTkiLCJqdGkiOiINTQZTYYWVmNjUNGYzMWQ3NDFiNGEyZjM3MDBkNDk5ZWEzMTBlNTczMDFjYjlhYTAyOWU2Y2Y4OWFiZmQyMGVmZjg0NGYNGUwYjA4NSIsImlhdCI6MTcwNjEwODQ4My4wMTA0NjksIm5iZiI6MTcwNjEwODQ4My4wMTA0NzIsImV4cCI6MjAyMTcyNzY4Mi45NzY3MSwic3ViIjoiMTUzOTA3MSIsInNjb3BlcyI6W9.VZTipL_ZOKHvUVoIvl2g-9PZ5rgvBUkaX3kYjXs6U5iCeHyAG7o4lu-VaUU26vQYLTn-WMM3rDfWovtITag-abaop9JP_n_zfIp7dJAA0nhKPt_phRQVZqLyONsjuQUmWDDvTYCg5lHVLpaiPrgkgT80cmF2B85qkpJbGrRYVCYoNXfZOLNbduBU8wjeZXMJsVTp-DDPeU6if5-NKhIUf32ZVC2ADg-5LspyRT5mlrWCCB-VzFD05Ru6VUr8DkA9GLc4y4fI3FozIfUH_Sf3Z_e-lY979eB6-IGbjg-EyvRuQMHlhi_ViclcGW2u0y4DBwXHvJ4cTJr2JOFhiJeUNjIAkvvYP6-NG8hMhdvQWUBp7RIDIeEn4fskDH7yaCHqo5hQZ-jcaTiSSr20pNTs8pd7Shk8aZZoaz-cavDLlRU6auXR_vMlbtfRIfk-49jRezcovfSsRb-WegaXETTVMHUynPCj8FYnd2ib6FVrhAnicmY7L5Lva + Accept-Encoding: + - gzip;q=.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 3 Jan 2024 6:06:30 GMT + Content-Type: + - application/vnd.api+json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - no-cache, private + X-Ratelimit-Limit: + - '300' + X-Ratelimit-Remaining: + - '299' + Access-Control-Allow-Origin: + - "*" + Apigw-Requestid: + - SaZDipRCYcEMRA= + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=yDGZHa%2FqJokQAhsNkHErJn3QBHeWkDbybHI8n4vRYbMHYSVNw4mV%2FPRLYKGPlLudviFLkYswjDdYKlWL7fl0njW4yi%2B%2BiDNHEt64q48UAg%2B6RvAtJ82YTy5zOtHZkSfPeVnZw%3D%3D"}],"group":"cf-nel","ma_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","ma_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 84e333c7bd7477b2-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"jsonapi":{"version":".0"},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"},"data":{"type":"subscriptions","id":"253735","attributes":{"store_id":5385,"customer_id":29429,"order_id":20584,"order_item_id":976428,"product_id":74873,"variant_id":225676,"product_name":"Payments + App","variant_name":"20 a month","user_name":"A TEST","user_email":"dean+9f2d78@voupe.dev","status":"active","status_formatted":"Active","card_brand":"visa","card_last_four":"4242","pause":null,"cancelled":false,"trial_ends_at":null,"billing_anchor":3,"first_subscription_item":{"id":20746,"subscription_id":253735,"price_id":259336,"quantity":,"is_usage_based":false,"created_at":"2024-0-3T5:38:4.000000Z","updated_at":"2024-0-3T5:38:4.000000Z"},"urls":{"update_payment_method":"https:\/\/voupedev.lemonsqueezy.com\/subscription\/253735\/payment-details?epires=706803590&signature=4782457b66b7acdc43fb842aaf00047354bbe0b4667a67f6ced7b4f","customer_portal":"https:\/\/voupedev.lemonsqueezy.com\/billing?epires=706738790&test_mode=&user=932202&signature=fd225e98d07b4937c0cdfb2aaeaf4e6d29aa0e52c27ddb98e8786e9"},"renews_at":"2024-02-29T5:38:35.000000Z","ends_at":null,"created_at":"2024-0-3T5:38:36.000000Z","updated_at":"2024-0-3T6:06:30.000000Z","test_mode":true},"relationships":{"store":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/store","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/store"}},"customer":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/customer","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/customer"}},"order":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order"}},"order-item":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order-item","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order-item"}},"product":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/product","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/product"}},"variant":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/variant","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/variant"}},"subscription-items":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-items","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-items"}},"subscription-invoices":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-invoices","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-invoices"}}},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"}}}' + recorded_at: Wed, 31 Jan 2024 16:06:31 GMT +- request: + method: get + uri: https://api.lemonsqueezy.com/v/subscriptions/253735 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - lemonsqueezy/v.0.0 (github.com/deanpcmad/lemonsqueezy) + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + Authorization: + - Bearer eyJ0eXAiOiJKVQiLCJhbGciOiJSUzINiJ9.eyJhdWQiOiI5NGQOWNlZikYmI4LTRlYTUtYjE3OCkMjU0MGZjZDY5MTkiLCJqdGkiOiINTQZTYYWVmNjUNGYzMWQ3NDFiNGEyZjM3MDBkNDk5ZWEzMTBlNTczMDFjYjlhYTAyOWU2Y2Y4OWFiZmQyMGVmZjg0NGYNGUwYjA4NSIsImlhdCI6MTcwNjEwODQ4My4wMTA0NjksIm5iZiI6MTcwNjEwODQ4My4wMTA0NzIsImV4cCI6MjAyMTcyNzY4Mi45NzY3MSwic3ViIjoiMTUzOTA3MSIsInNjb3BlcyI6W9.VZTipL_ZOKHvUVoIvl2g-9PZ5rgvBUkaX3kYjXs6U5iCeHyAG7o4lu-VaUU26vQYLTn-WMM3rDfWovtITag-abaop9JP_n_zfIp7dJAA0nhKPt_phRQVZqLyONsjuQUmWDDvTYCg5lHVLpaiPrgkgT80cmF2B85qkpJbGrRYVCYoNXfZOLNbduBU8wjeZXMJsVTp-DDPeU6if5-NKhIUf32ZVC2ADg-5LspyRT5mlrWCCB-VzFD05Ru6VUr8DkA9GLc4y4fI3FozIfUH_Sf3Z_e-lY979eB6-IGbjg-EyvRuQMHlhi_ViclcGW2u0y4DBwXHvJ4cTJr2JOFhiJeUNjIAkvvYP6-NG8hMhdvQWUBp7RIDIeEn4fskDH7yaCHqo5hQZ-jcaTiSSr20pNTs8pd7Shk8aZZoaz-cavDLlRU6auXR_vMlbtfRIfk-49jRezcovfSsRb-WegaXETTVMHUynPCj8FYnd2ib6FVrhAnicmY7L5Lva + Accept-Encoding: + - gzip;q=.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 3 Jan 2024 6:06:3 GMT + Content-Type: + - application/vnd.api+json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - no-cache, private + X-Ratelimit-Limit: + - '300' + X-Ratelimit-Remaining: + - '298' + Apigw-Requestid: + - SaZIiZsiYcEJyA= + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=jnLh9BmH5gJrwW4jUai77HS%2B5VvjQB0zN8%2BXeunA%2B2cG27H33ymXJ6Ak75tyMibepH%2FMIdgK%2FRMcNTRRzORhsk3a%2FmMWb8YKt8Z0VLaPgp%2BKCvGJ6zuLekgQIGr3wmtuA%3D%3D"}],"group":"cf-nel","ma_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","ma_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 84e333cb288d7796-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"jsonapi":{"version":".0"},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"},"data":{"type":"subscriptions","id":"253735","attributes":{"store_id":5385,"customer_id":29429,"order_id":20584,"order_item_id":976428,"product_id":74873,"variant_id":225676,"product_name":"Payments + App","variant_name":"20 a month","user_name":"A TEST","user_email":"dean+9f2d78@voupe.dev","status":"active","status_formatted":"Active","card_brand":"visa","card_last_four":"4242","pause":null,"cancelled":false,"trial_ends_at":null,"billing_anchor":3,"first_subscription_item":{"id":20746,"subscription_id":253735,"price_id":259336,"quantity":,"is_usage_based":false,"created_at":"2024-0-3T5:38:4.000000Z","updated_at":"2024-0-3T5:38:4.000000Z"},"urls":{"update_payment_method":"https:\/\/voupedev.lemonsqueezy.com\/subscription\/253735\/payment-details?epires=70680359&signature=bf59fd6607d95dd360e827cd4ebaf283d9bc395b73024cad6c208ab0e38e827","customer_portal":"https:\/\/voupedev.lemonsqueezy.com\/billing?epires=70673879&test_mode=&user=932202&signature=2386ea7e04372a6e55505bbb2d0f8435b0bfecc5db676249707e3920f60b8"},"renews_at":"2024-02-29T5:38:35.000000Z","ends_at":null,"created_at":"2024-0-3T5:38:36.000000Z","updated_at":"2024-0-3T6:06:30.000000Z","test_mode":true},"relationships":{"store":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/store","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/store"}},"customer":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/customer","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/customer"}},"order":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order"}},"order-item":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order-item","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order-item"}},"product":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/product","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/product"}},"variant":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/variant","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/variant"}},"subscription-items":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-items","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-items"}},"subscription-invoices":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-invoices","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-invoices"}}},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"}}}' + recorded_at: Wed, 31 Jan 2024 16:06:31 GMT +recorded_with: VCR 6.2.0 diff --git a/test/vcr_cassettes/test_lemon_squeezy_processor_subscription.yml b/test/vcr_cassettes/test_lemon_squeezy_processor_subscription.yml new file mode 100644 index 00000000..198026d1 --- /dev/null +++ b/test/vcr_cassettes/test_lemon_squeezy_processor_subscription.yml @@ -0,0 +1,60 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.lemonsqueezy.com/v/subscriptions/253735 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - lemonsqueezy/v.0.0 (github.com/deanpcmad/lemonsqueezy) + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + Authorization: + - Bearer eyJ0eXAiOiJKVQiLCJhbGciOiJSUzINiJ9.eyJhdWQiOiI5NGQOWNlZikYmI4LTRlYTUtYjE3OCkMjU0MGZjZDY5MTkiLCJqdGkiOiINTQZTYYWVmNjUNGYzMWQ3NDFiNGEyZjM3MDBkNDk5ZWEzMTBlNTczMDFjYjlhYTAyOWU2Y2Y4OWFiZmQyMGVmZjg0NGYNGUwYjA4NSIsImlhdCI6MTcwNjEwODQ4My4wMTA0NjksIm5iZiI6MTcwNjEwODQ4My4wMTA0NzIsImV4cCI6MjAyMTcyNzY4Mi45NzY3MSwic3ViIjoiMTUzOTA3MSIsInNjb3BlcyI6W9.VZTipL_ZOKHvUVoIvl2g-9PZ5rgvBUkaX3kYjXs6U5iCeHyAG7o4lu-VaUU26vQYLTn-WMM3rDfWovtITag-abaop9JP_n_zfIp7dJAA0nhKPt_phRQVZqLyONsjuQUmWDDvTYCg5lHVLpaiPrgkgT80cmF2B85qkpJbGrRYVCYoNXfZOLNbduBU8wjeZXMJsVTp-DDPeU6if5-NKhIUf32ZVC2ADg-5LspyRT5mlrWCCB-VzFD05Ru6VUr8DkA9GLc4y4fI3FozIfUH_Sf3Z_e-lY979eB6-IGbjg-EyvRuQMHlhi_ViclcGW2u0y4DBwXHvJ4cTJr2JOFhiJeUNjIAkvvYP6-NG8hMhdvQWUBp7RIDIeEn4fskDH7yaCHqo5hQZ-jcaTiSSr20pNTs8pd7Shk8aZZoaz-cavDLlRU6auXR_vMlbtfRIfk-49jRezcovfSsRb-WegaXETTVMHUynPCj8FYnd2ib6FVrhAnicmY7L5Lva + Accept-Encoding: + - gzip;q=.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 3 Jan 2024 6:04:30 GMT + Content-Type: + - application/vnd.api+json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - no-cache, private + X-Ratelimit-Limit: + - '300' + X-Ratelimit-Remaining: + - '299' + Access-Control-Allow-Origin: + - "*" + Apigw-Requestid: + - SaZeVijUCYcEJXg= + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=vnwyPWLWtaX5t0f2ACfJmvoXlZEDX%2BbUi9eysugMn%2BVZrrF%2FXkYk3SEQP3u8mD%2FmENQDElpBOU9jvpFuHIzIbZvAKBE4g2zFlRlke%2BaIbTddW3tqmc%2B6stDGqE97diIXnmgutOqTg%3D%3D"}],"group":"cf-nel","ma_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","ma_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 84e330da2cd948c9-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"jsonapi":{"version":".0"},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"},"data":{"type":"subscriptions","id":"253735","attributes":{"store_id":5385,"customer_id":29429,"order_id":20584,"order_item_id":976428,"product_id":74873,"variant_id":225676,"product_name":"Payments + App","variant_name":"Default","user_name":"A TEST","user_email":"dean+9f2d78@voupe.dev","status":"active","status_formatted":"Active","card_brand":"visa","card_last_four":"4242","pause":null,"cancelled":false,"trial_ends_at":null,"billing_anchor":3,"first_subscription_item":{"id":20746,"subscription_id":253735,"price_id":259336,"quantity":,"is_usage_based":false,"created_at":"2024-0-3T5:38:4.000000Z","updated_at":"2024-0-3T5:38:4.000000Z"},"urls":{"update_payment_method":"https:\/\/voupedev.lemonsqueezy.com\/subscription\/253735\/payment-details?epires=706803470&signature=6ab2cab3e25bf96873644c75b06649fb3302fddee93b6244cc9b9c97c528","customer_portal":"https:\/\/voupedev.lemonsqueezy.com\/billing?epires=706738670&test_mode=&user=932202&signature=0bfc646e978947a58b2260cb49aaeb43d96fb7c4eeabee40358d47b04a33"},"renews_at":"2024-02-29T5:38:35.000000Z","ends_at":null,"created_at":"2024-0-3T5:38:36.000000Z","updated_at":"2024-0-3T5:39:.000000Z","test_mode":true},"relationships":{"store":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/store","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/store"}},"customer":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/customer","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/customer"}},"order":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order"}},"order-item":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order-item","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order-item"}},"product":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/product","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/product"}},"variant":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/variant","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/variant"}},"subscription-items":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-items","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-items"}},"subscription-invoices":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-invoices","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-invoices"}}},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"}}}' + recorded_at: Wed, 31 Jan 2024 16:04:31 GMT +recorded_with: VCR 6.2.0 diff --git a/test/vcr_cassettes/test_retrieving_a_lemon_squeezy_subscription.yml b/test/vcr_cassettes/test_retrieving_a_lemon_squeezy_subscription.yml new file mode 100644 index 00000000..7a8c78fe --- /dev/null +++ b/test/vcr_cassettes/test_retrieving_a_lemon_squeezy_subscription.yml @@ -0,0 +1,117 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.lemonsqueezy.com/v/subscriptions/253735 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - lemonsqueezy/v.0.0 (github.com/deanpcmad/lemonsqueezy) + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + Authorization: + - Bearer eyJ0eXAiOiJKVQiLCJhbGciOiJSUzINiJ9.eyJhdWQiOiI5NGQOWNlZikYmI4LTRlYTUtYjE3OCkMjU0MGZjZDY5MTkiLCJqdGkiOiINTQZTYYWVmNjUNGYzMWQ3NDFiNGEyZjM3MDBkNDk5ZWEzMTBlNTczMDFjYjlhYTAyOWU2Y2Y4OWFiZmQyMGVmZjg0NGYNGUwYjA4NSIsImlhdCI6MTcwNjEwODQ4My4wMTA0NjksIm5iZiI6MTcwNjEwODQ4My4wMTA0NzIsImV4cCI6MjAyMTcyNzY4Mi45NzY3MSwic3ViIjoiMTUzOTA3MSIsInNjb3BlcyI6W9.VZTipL_ZOKHvUVoIvl2g-9PZ5rgvBUkaX3kYjXs6U5iCeHyAG7o4lu-VaUU26vQYLTn-WMM3rDfWovtITag-abaop9JP_n_zfIp7dJAA0nhKPt_phRQVZqLyONsjuQUmWDDvTYCg5lHVLpaiPrgkgT80cmF2B85qkpJbGrRYVCYoNXfZOLNbduBU8wjeZXMJsVTp-DDPeU6if5-NKhIUf32ZVC2ADg-5LspyRT5mlrWCCB-VzFD05Ru6VUr8DkA9GLc4y4fI3FozIfUH_Sf3Z_e-lY979eB6-IGbjg-EyvRuQMHlhi_ViclcGW2u0y4DBwXHvJ4cTJr2JOFhiJeUNjIAkvvYP6-NG8hMhdvQWUBp7RIDIeEn4fskDH7yaCHqo5hQZ-jcaTiSSr20pNTs8pd7Shk8aZZoaz-cavDLlRU6auXR_vMlbtfRIfk-49jRezcovfSsRb-WegaXETTVMHUynPCj8FYnd2ib6FVrhAnicmY7L5Lva + Accept-Encoding: + - gzip;q=.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 3 Jan 2024 6:03:0 GMT + Content-Type: + - application/vnd.api+json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Ratelimit-Limit: + - '300' + X-Ratelimit-Remaining: + - '299' + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - no-cache, private + Apigw-Requestid: + - SaZRhBGiYcEJdA= + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=udibN4lCgEsrijuuLm6kjW975Uz%2BX%2FWLCn%2FAKLLowmauZF3NriOcNKnjfo20A3ModfJ0%2FJ8fQCNVDt804NjE0aBpgCNvU%2BE6cp5VYoRFu%2Bb6nqgf5KWm8%2BrwZbKBNElovSA%3D%3D"}],"group":"cf-nel","ma_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","ma_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 84e32ee35aa23c3-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"jsonapi":{"version":".0"},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"},"data":{"type":"subscriptions","id":"253735","attributes":{"store_id":5385,"customer_id":29429,"order_id":20584,"order_item_id":976428,"product_id":74873,"variant_id":225676,"product_name":"Payments + App","variant_name":"Default","user_name":"A TEST","user_email":"dean+9f2d78@voupe.dev","status":"active","status_formatted":"Active","card_brand":"visa","card_last_four":"4242","pause":null,"cancelled":false,"trial_ends_at":null,"billing_anchor":3,"first_subscription_item":{"id":20746,"subscription_id":253735,"price_id":259336,"quantity":,"is_usage_based":false,"created_at":"2024-0-3T5:38:4.000000Z","updated_at":"2024-0-3T5:38:4.000000Z"},"urls":{"update_payment_method":"https:\/\/voupedev.lemonsqueezy.com\/subscription\/253735\/payment-details?epires=706803390&signature=3f4b7f495a637653da699d48700be2bff74794de44edc69c555b5e5e0fed","customer_portal":"https:\/\/voupedev.lemonsqueezy.com\/billing?epires=706738590&test_mode=&user=932202&signature=08ad603978293925e4afcd4dc9f3cf6b200884788d2d2608f4d327ca67e7"},"renews_at":"2024-02-29T5:38:35.000000Z","ends_at":null,"created_at":"2024-0-3T5:38:36.000000Z","updated_at":"2024-0-3T5:39:.000000Z","test_mode":true},"relationships":{"store":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/store","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/store"}},"customer":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/customer","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/customer"}},"order":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order"}},"order-item":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order-item","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order-item"}},"product":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/product","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/product"}},"variant":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/variant","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/variant"}},"subscription-items":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-items","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-items"}},"subscription-invoices":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-invoices","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-invoices"}}},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"}}}' + recorded_at: Wed, 31 Jan 2024 16:03:11 GMT +- request: + method: get + uri: https://api.lemonsqueezy.com/v/subscriptions/253735 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - lemonsqueezy/v.0.0 (github.com/deanpcmad/lemonsqueezy) + Accept: + - application/vnd.api+json + Content-Type: + - application/vnd.api+json + Authorization: + - Bearer eyJ0eXAiOiJKVQiLCJhbGciOiJSUzINiJ9.eyJhdWQiOiI5NGQOWNlZikYmI4LTRlYTUtYjE3OCkMjU0MGZjZDY5MTkiLCJqdGkiOiINTQZTYYWVmNjUNGYzMWQ3NDFiNGEyZjM3MDBkNDk5ZWEzMTBlNTczMDFjYjlhYTAyOWU2Y2Y4OWFiZmQyMGVmZjg0NGYNGUwYjA4NSIsImlhdCI6MTcwNjEwODQ4My4wMTA0NjksIm5iZiI6MTcwNjEwODQ4My4wMTA0NzIsImV4cCI6MjAyMTcyNzY4Mi45NzY3MSwic3ViIjoiMTUzOTA3MSIsInNjb3BlcyI6W9.VZTipL_ZOKHvUVoIvl2g-9PZ5rgvBUkaX3kYjXs6U5iCeHyAG7o4lu-VaUU26vQYLTn-WMM3rDfWovtITag-abaop9JP_n_zfIp7dJAA0nhKPt_phRQVZqLyONsjuQUmWDDvTYCg5lHVLpaiPrgkgT80cmF2B85qkpJbGrRYVCYoNXfZOLNbduBU8wjeZXMJsVTp-DDPeU6if5-NKhIUf32ZVC2ADg-5LspyRT5mlrWCCB-VzFD05Ru6VUr8DkA9GLc4y4fI3FozIfUH_Sf3Z_e-lY979eB6-IGbjg-EyvRuQMHlhi_ViclcGW2u0y4DBwXHvJ4cTJr2JOFhiJeUNjIAkvvYP6-NG8hMhdvQWUBp7RIDIeEn4fskDH7yaCHqo5hQZ-jcaTiSSr20pNTs8pd7Shk8aZZoaz-cavDLlRU6auXR_vMlbtfRIfk-49jRezcovfSsRb-WegaXETTVMHUynPCj8FYnd2ib6FVrhAnicmY7L5Lva + Accept-Encoding: + - gzip;q=.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 3 Jan 2024 6:03:0 GMT + Content-Type: + - application/vnd.api+json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Ratelimit-Limit: + - '300' + X-Ratelimit-Remaining: + - '298' + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - no-cache, private + Apigw-Requestid: + - SaZR2hABCYcEKKw= + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=HOUceCacIrVyU2ZYntNnPLdWkR6egNbHSMt0%2F7Q5QZanSwdHlOIXSw%2By5XbEtUPgUHlPcA%2FnB3P%2FXjcwg%2FeFX7jDKbUHzFvE9B0kvLI6Eu26LhroKr5n5sLh5YHVFoXuJ03mnQ%3D%3D"}],"group":"cf-nel","ma_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","ma_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 84e32ee77d26544-LHR + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"jsonapi":{"version":".0"},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"},"data":{"type":"subscriptions","id":"253735","attributes":{"store_id":5385,"customer_id":29429,"order_id":20584,"order_item_id":976428,"product_id":74873,"variant_id":225676,"product_name":"Payments + App","variant_name":"Default","user_name":"A TEST","user_email":"dean+9f2d78@voupe.dev","status":"active","status_formatted":"Active","card_brand":"visa","card_last_four":"4242","pause":null,"cancelled":false,"trial_ends_at":null,"billing_anchor":3,"first_subscription_item":{"id":20746,"subscription_id":253735,"price_id":259336,"quantity":,"is_usage_based":false,"created_at":"2024-0-3T5:38:4.000000Z","updated_at":"2024-0-3T5:38:4.000000Z"},"urls":{"update_payment_method":"https:\/\/voupedev.lemonsqueezy.com\/subscription\/253735\/payment-details?epires=706803390&signature=3f4b7f495a637653da699d48700be2bff74794de44edc69c555b5e5e0fed","customer_portal":"https:\/\/voupedev.lemonsqueezy.com\/billing?epires=706738590&test_mode=&user=932202&signature=08ad603978293925e4afcd4dc9f3cf6b200884788d2d2608f4d327ca67e7"},"renews_at":"2024-02-29T5:38:35.000000Z","ends_at":null,"created_at":"2024-0-3T5:38:36.000000Z","updated_at":"2024-0-3T5:39:.000000Z","test_mode":true},"relationships":{"store":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/store","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/store"}},"customer":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/customer","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/customer"}},"order":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order"}},"order-item":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/order-item","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/order-item"}},"product":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/product","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/product"}},"variant":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/variant","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/variant"}},"subscription-items":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-items","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-items"}},"subscription-invoices":{"links":{"related":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/subscription-invoices","self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735\/relationships\/subscription-invoices"}}},"links":{"self":"https:\/\/api.lemonsqueezy.com\/v\/subscriptions\/253735"}}}' + recorded_at: Wed, 31 Jan 2024 16:03:11 GMT +recorded_with: VCR 6.2.0