From 19db99f27dd27fcb2d5923df22fb8bcf97f66986 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 24 Jan 2024 15:43:23 +0000 Subject: [PATCH 01/18] Initial work on Lemon Squeezy integration --- Gemfile | 1 + Gemfile.lock | 3 + app/models/pay/charge.rb | 2 +- app/models/pay/customer.rb | 2 +- app/models/pay/subscription.rb | 2 +- lib/pay.rb | 1 + lib/pay/engine.rb | 2 + lib/pay/lemon_squeezy.rb | 56 ++++++ lib/pay/lemon_squeezy/billable.rb | 90 +++++++++ lib/pay/lemon_squeezy/charge.rb | 68 +++++++ lib/pay/lemon_squeezy/error.rb | 7 + lib/pay/lemon_squeezy/payment_method.rb | 40 ++++ lib/pay/lemon_squeezy/subscription.rb | 189 ++++++++++++++++++ lib/pay/lemon_squeezy/webhooks/payment.rb | 11 + .../lemon_squeezy/webhooks/subscription.rb | 11 + 15 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 lib/pay/lemon_squeezy.rb create mode 100644 lib/pay/lemon_squeezy/billable.rb create mode 100644 lib/pay/lemon_squeezy/charge.rb create mode 100644 lib/pay/lemon_squeezy/error.rb create mode 100644 lib/pay/lemon_squeezy/payment_method.rb create mode 100644 lib/pay/lemon_squeezy/subscription.rb create mode 100644 lib/pay/lemon_squeezy/webhooks/payment.rb create mode 100644 lib/pay/lemon_squeezy/webhooks/subscription.rb diff --git a/Gemfile b/Gemfile index 297024d3..c4e8ebb2 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem "webmock" gem "braintree", ">= 2.92.0" gem "stripe", "~> 10.4" gem "paddle", "~> 2.1" +gem "lemonsqueezy", "~> 1.0" gem "receipts" gem "prawn", github: "prawnpdf/prawn" diff --git a/Gemfile.lock b/Gemfile.lock index 90b3ea9d..21163c1f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -140,6 +140,8 @@ GEM reline (>= 0.4.2) json (2.7.1) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) loofah (2.22.0) crass (~> 1.0.2) @@ -327,6 +329,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap diff --git a/app/models/pay/charge.rb b/app/models/pay/charge.rb index 468944d9..a1ff694a 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 c0696734..2e81fd0b 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/lib/pay.rb b/lib/pay.rb index 52912847..919872f4 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" 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..e0ce9d42 --- /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.passthrough(owner:, **options) + owner.to_sgid.to_s + end + + def self.parse_passthrough(passthrough) + JSON.parse(passthrough) + end + + def self.owner_from_passthrough(passthrough) + GlobalID::Locator.locate_signed parse_passthrough(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..f6706fd9 --- /dev/null +++ b/lib/pay/lemon_squeezy/billable.rb @@ -0,0 +1,90 @@ +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 = {}) + # return Pay::Error unless options + + # items = options[:items] + # opts = options.except(:items).merge(customer_id: processor_id) + # transaction = ::Paddle::Transaction.create(items: items, **opts) + + # attrs = { + # amount: transaction.details.totals.grand_total, + # created_at: transaction.created_at, + # currency: transaction.currency_code, + # metadata: transaction.details.line_items&.first&.id + # } + + # charge = pay_customer.charges.find_or_initialize_by(processor_id: transaction.id) + # charge.update(attrs) + # charge + # rescue ::Paddle::Error => e + # raise Pay::LemonSqueezy::Error, e + end + + def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options) + # pass + end + + # Paddle does not use payment method tokens. The method signature has it here + # to have a uniform API with the other payment processors. + def add_payment_method(token = nil, default: true) + # Pay::LemonSqueezy::PaymentMethod.sync(pay_customer: pay_customer) + end + + def trial_end_date(subscription) + # return unless subscription.state == "trialing" + # Time.zone.parse(subscription.next_payment[:date]).end_of_day + end + + def processor_subscription(subscription_id, options = {}) + # ::Paddle::Subscription.retrieve(id: subscription_id, **options) + # rescue ::Paddle::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..ae1eeb1f --- /dev/null +++ b/lib/pay/lemon_squeezy/charge.rb @@ -0,0 +1,68 @@ +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 charge details from the API if we already have it + object ||= ::Paddle::Transaction.retrieve(id: charge_id) + + # Ignore transactions that aren't completed + return unless object.status == "completed" + + # Ignore charges without a Customer + return if object.customer_id.blank? + + pay_customer = Pay::Customer.find_by(processor: :paddle_billing, processor_id: object.customer_id) + return unless pay_customer + + # Ignore transactions that are payment method changes + # But update the customer's payment method + if object.origin == "subscription_payment_method_change" + Pay::PaddleBilling::PaymentMethod.sync(pay_customer: pay_customer, attributes: object.payments.first) + return + end + + attrs = { + amount: object.details.totals.grand_total, + created_at: object.created_at, + currency: object.currency_code, + metadata: object.details.line_items&.first&.id, + subscription: pay_customer.subscriptions.find_by(processor_id: object.subscription_id) + } + + if object.payment + case object.payment.method_details.type.downcase + when "card" + attrs[:payment_method_type] = "card" + attrs[:brand] = details.card.type + attrs[:exp_month] = details.card.expiry_month + attrs[:exp_year] = details.card.expiry_year + attrs[:last4] = details.card.last4 + when "paypal" + attrs[:payment_method_type] = "paypal" + end + + # Update customer's payment method + Pay::PaddleBilling::PaymentMethod.sync(pay_customer: pay_customer, attributes: object.payments.first) + end + + # Update or create the charge + if (pay_charge = pay_customer.charges.find_by(processor_id: object.id)) + pay_charge.with_lock do + pay_charge.update!(attrs) + end + pay_charge + else + pay_customer.charges.create!(attrs.merge(processor_id: object.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..8bea19b4 --- /dev/null +++ b/lib/pay/lemon_squeezy/payment_method.rb @@ -0,0 +1,40 @@ +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:) + details = attributes.method_details + attrs = { + type: details.type.downcase + } + + case details.type.downcase + when "card" + attrs[:brand] = details.card.type + attrs[:last4] = details.card.last4 + attrs[:exp_month] = details.card.expiry_month + attrs[:exp_year] = details.card.expiry_year + end + + payment_method = pay_customer.payment_methods.find_or_initialize_by(processor_id: attributes.stored_payment_method_id) + 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..7ac018b7 --- /dev/null +++ b/lib/pay/lemon_squeezy/subscription.rb @@ -0,0 +1,189 @@ +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_from_transaction(transaction_id) + transaction = ::Paddle::Transaction.retrieve(id: transaction_id) + sync(transaction.subscription_id) if transaction.subscription_id + end + + 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 ||= ::Paddle::Subscription.retrieve(id: subscription_id) + + pay_customer = Pay::Customer.find_by(processor: :paddle_billing, processor_id: object.customer_id) + return unless pay_customer + + attributes = { + current_period_end: object.current_billing_period&.ends_at, + current_period_start: object.current_billing_period&.starts_at, + ends_at: (object.canceled_at ? Time.parse(object.canceled_at) : nil), + metadata: object.custom_data, + paddle_cancel_url: object.management_urls&.cancel, + paddle_update_url: object.management_urls&.update_payment_method, + pause_starts_at: (object.paused_at ? Time.parse(object.paused_at) : nil), + status: object.status + } + + if object.items&.first + item = object.items.first + attributes[:processor_plan] = item.price.id + attributes[:quantity] = item.quantity + end + + case attributes[:status] + when "canceled" + # Remove payment methods since customer cannot be reused after cancelling + Pay::PaymentMethod.where(customer_id: object.customer_id).destroy_all + when "trialing" + attributes[:trial_ends_at] = Time.parse(object.next_billed_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 + + case object.scheduled_change&.action + when "cancel" + attributes[:ends_at] = Time.parse(object.scheduled_change.effective_at) + when "pause" + attributes[:pause_starts_at] = Time.parse(object.scheduled_change.effective_at) + when "resume" + attributes[:pause_resumes_at] = Time.parse(object.scheduled_change.effective_at) + 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) + @paddle_billing_subscription ||= ::Paddle::Subscription.retrieve(id: processor_id, **options) + end + + # Get a transaction to update payment method + def payment_method_transaction + ::Paddle::Subscription.get_transaction(id: processor_id) + end + + # If a subscription is paused, cancel immediately + # Otherwise, cancel at period end + def cancel(**options) + return if canceled? + + response = ::Paddle::Subscription.cancel( + id: processor_id, + effective_from: options.fetch(:effective_from, (paused? ? "immediately" : "next_billing_period")) + ) + pay_subscription.update( + status: response.status, + ends_at: response.scheduled_change.effective_at + ) + rescue ::Paddle::Error => e + raise Pay::PaddleBilling::Error, e + end + + def cancel_now!(**options) + cancel(options.merge(effective_from: "immediately")) + rescue ::Paddle::Error => e + raise Pay::PaddleBilling::Error, e + 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::PaddleBilling::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 + response = ::Paddle::Subscription.pause(id: processor_id) + pay_subscription.update!(status: :paused, pause_starts_at: response.scheduled_change.effective_at) + rescue ::Paddle::Error => e + raise Pay::PaddleBilling::Error, e + end + + def resumable? + paused? + end + + def resume + unless resumable? + raise StandardError, "You can only resume paused subscriptions." + end + + # Paddle Billing API only allows "resuming" subscriptions when they are paused + # So cancel the scheduled change if it is in the future + if paused? && pause_starts_at? && Time.current < pause_starts_at + ::Paddle::Subscription.update(id: processor_id, scheduled_change: nil) + else + ::Paddle::Subscription.resume(id: processor_id, effective_from: "immediately") + end + + pay_subscription.update(status: :active, pause_starts_at: nil) + rescue ::Paddle::Error => e + raise Pay::PaddleBilling::Error, e + end + + def swap(plan, **options) + items = [{ + price_id: plan, + quantity: quantity || 1 + }] + + ::Paddle::Subscription.update(id: processor_id, items: items, proration_billing_mode: "prorated_immediately") + pay_subscription.update(processor_plan: plan, 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..fe9a7282 --- /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.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..871b5d8d --- /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.id, object: event) + end + end + end + end +end From 6623c29b6b0e31487682a4e9b3beb86f60e3a5af Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 24 Jan 2024 16:03:54 +0000 Subject: [PATCH 02/18] LS webhook controller, with working signature check --- .../pay/webhooks/lemon_squeezy_controller.rb | 45 +++++++++++++++++++ app/models/pay/webhook.rb | 4 +- config/routes.rb | 1 + lib/pay/lemon_squeezy.rb | 4 ++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 app/controllers/pay/webhooks/lemon_squeezy_controller.rb 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..cfbd4389 --- /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) + hmac == signature + end + + def verify_params + params.except(:action, :controller).permit! + end + end + end +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/lib/pay/lemon_squeezy.rb b/lib/pay/lemon_squeezy.rb index e0ce9d42..05995808 100644 --- a/lib/pay/lemon_squeezy.rb +++ b/lib/pay/lemon_squeezy.rb @@ -31,6 +31,10 @@ 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 From cf8ef839b87ff45fd0a860014bb2be5e5a0b0e51 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 24 Jan 2024 17:05:17 +0000 Subject: [PATCH 03/18] LS subscription sync working --- app/models/pay/subscription.rb | 2 +- lib/pay/lemon_squeezy.rb | 2 +- lib/pay/lemon_squeezy/subscription.rb | 120 +++++++----------- .../lemon_squeezy/webhooks/subscription.rb | 2 +- 4 files changed, 52 insertions(+), 74 deletions(-) diff --git a/app/models/pay/subscription.rb b/app/models/pay/subscription.rb index 2e81fd0b..1c1836d0 100644 --- a/app/models/pay/subscription.rb +++ b/app/models/pay/subscription.rb @@ -13,7 +13,7 @@ class Subscription < Pay::ApplicationRecord scope :canceled, -> { where.not(ends_at: nil) } scope :cancelled, -> { canceled } scope :on_grace_period, -> { where("#{table_name}.ends_at IS NOT NULL AND #{table_name}.ends_at > ?", Time.current) } - scope :active, -> { where(status: ["trialing", "active"]).pause_not_started.where("#{table_name}.ends_at IS NULL OR #{table_name}.ends_at > ?", Time.current).where("trial_ends_at IS NULL OR trial_ends_at > ?", Time.current) } + scope :active, -> { where(status: ["trialing", "on_trial", "active"]).pause_not_started.where("#{table_name}.ends_at IS NULL OR #{table_name}.ends_at > ?", Time.current).where("trial_ends_at IS NULL OR trial_ends_at > ?", Time.current) } scope :paused, -> { where(status: "paused").or(where("pause_starts_at <= ?", Time.current)) } scope :pause_not_started, -> { where("pause_starts_at IS NULL OR pause_starts_at > ?", Time.current) } scope :active_or_paused, -> { active.or(paused) } diff --git a/lib/pay/lemon_squeezy.rb b/lib/pay/lemon_squeezy.rb index 05995808..1156b3e1 100644 --- a/lib/pay/lemon_squeezy.rb +++ b/lib/pay/lemon_squeezy.rb @@ -44,7 +44,7 @@ def self.parse_passthrough(passthrough) end def self.owner_from_passthrough(passthrough) - GlobalID::Locator.locate_signed parse_passthrough(passthrough) + GlobalID::Locator.locate_signed passthrough rescue JSON::ParserError nil end diff --git a/lib/pay/lemon_squeezy/subscription.rb b/lib/pay/lemon_squeezy/subscription.rb index 7ac018b7..2ccd69af 100644 --- a/lib/pay/lemon_squeezy/subscription.rb +++ b/lib/pay/lemon_squeezy/subscription.rb @@ -29,51 +29,46 @@ def self.sync_from_transaction(transaction_id) 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 ||= ::Paddle::Subscription.retrieve(id: subscription_id) + 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 - pay_customer = Pay::Customer.find_by(processor: :paddle_billing, processor_id: object.customer_id) return unless pay_customer attributes = { - current_period_end: object.current_billing_period&.ends_at, - current_period_start: object.current_billing_period&.starts_at, - ends_at: (object.canceled_at ? Time.parse(object.canceled_at) : nil), - metadata: object.custom_data, - paddle_cancel_url: object.management_urls&.cancel, - paddle_update_url: object.management_urls&.update_payment_method, - pause_starts_at: (object.paused_at ? Time.parse(object.paused_at) : nil), - status: object.status + 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 } - if object.items&.first - item = object.items.first - attributes[:processor_plan] = item.price.id - attributes[:quantity] = item.quantity - end + attributes[:processor_plan] = attrs.first_subscription_item.price_id + attributes[:quantity] = attrs.first_subscription_item.quantity case attributes[:status] - when "canceled" + when "cancelled" # Remove payment methods since customer cannot be reused after cancelling - Pay::PaymentMethod.where(customer_id: object.customer_id).destroy_all - when "trialing" - attributes[:trial_ends_at] = Time.parse(object.next_billed_at) + 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) + # 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 - case object.scheduled_change&.action - when "cancel" - attributes[:ends_at] = Time.parse(object.scheduled_change.effective_at) - when "pause" - attributes[:pause_starts_at] = Time.parse(object.scheduled_change.effective_at) - when "resume" - attributes[:pause_resumes_at] = Time.parse(object.scheduled_change.effective_at) - end - # Update or create the subscription if (pay_subscription = pay_customer.subscriptions.find_by(processor_id: subscription_id)) pay_subscription.with_lock do @@ -90,35 +85,19 @@ def initialize(pay_subscription) end def subscription(**options) - @paddle_billing_subscription ||= ::Paddle::Subscription.retrieve(id: processor_id, **options) - end - - # Get a transaction to update payment method - def payment_method_transaction - ::Paddle::Subscription.get_transaction(id: processor_id) + @lemon_squeezy_subscription ||= ::LemonSqueezy::Subscription.retrieve(id: processor_id) end - # If a subscription is paused, cancel immediately - # Otherwise, cancel at period end def cancel(**options) return if canceled? - - response = ::Paddle::Subscription.cancel( - id: processor_id, - effective_from: options.fetch(:effective_from, (paused? ? "immediately" : "next_billing_period")) - ) - pay_subscription.update( - status: response.status, - ends_at: response.scheduled_change.effective_at - ) - rescue ::Paddle::Error => e - raise Pay::PaddleBilling::Error, e + 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) - cancel(options.merge(effective_from: "immediately")) - rescue ::Paddle::Error => e - raise Pay::PaddleBilling::Error, e + # Lemon Squeezy doesn't support cancelling immediately end def change_quantity(quantity, **options) @@ -129,7 +108,7 @@ def change_quantity(quantity, **options) ::Paddle::Subscription.update(id: processor_id, items: items, proration_billing_mode: "prorated_immediately") rescue ::Paddle::Error => e - raise Pay::PaddleBilling::Error, e + raise Pay::LemonSqueezy::Error, e end # A subscription could be set to cancel or pause in the future @@ -142,43 +121,42 @@ def paused? pay_subscription.status == "paused" end - def pause - response = ::Paddle::Subscription.pause(id: processor_id) - pay_subscription.update!(status: :paused, pause_starts_at: response.scheduled_change.effective_at) - rescue ::Paddle::Error => e - raise Pay::PaddleBilling::Error, e + 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? + paused? || canceled? end def resume unless resumable? - raise StandardError, "You can only resume paused subscriptions." + raise StandardError, "You can only resume paused or cancelled subscriptions" end - # Paddle Billing API only allows "resuming" subscriptions when they are paused - # So cancel the scheduled change if it is in the future if paused? && pause_starts_at? && Time.current < pause_starts_at - ::Paddle::Subscription.update(id: processor_id, scheduled_change: nil) + ::LemonSqueezy::Subscription.unpause(id: processor_id) else - ::Paddle::Subscription.resume(id: processor_id, effective_from: "immediately") + ::LemonSqueezy::Subscription.uncancel(id: processor_id) end pay_subscription.update(status: :active, pause_starts_at: nil) - rescue ::Paddle::Error => e - raise Pay::PaddleBilling::Error, e + 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) - items = [{ - price_id: plan, - quantity: quantity || 1 - }] + 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] - ::Paddle::Subscription.update(id: processor_id, items: items, proration_billing_mode: "prorated_immediately") - pay_subscription.update(processor_plan: plan, ends_at: nil, status: :active) + ::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 diff --git a/lib/pay/lemon_squeezy/webhooks/subscription.rb b/lib/pay/lemon_squeezy/webhooks/subscription.rb index 871b5d8d..14f9105a 100644 --- a/lib/pay/lemon_squeezy/webhooks/subscription.rb +++ b/lib/pay/lemon_squeezy/webhooks/subscription.rb @@ -3,7 +3,7 @@ module LemonSqueezy module Webhooks class Subscription def call(event) - Pay::LemonSqueezy::Subscription.sync(event.id, object: event) + Pay::LemonSqueezy::Subscription.sync(event.data.id, object: event) end end end From e916e3bfbf3913c5620c928372d6c4f74e0bee0b Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 24 Jan 2024 18:01:38 +0000 Subject: [PATCH 04/18] standard fix --- lib/pay/lemon_squeezy/billable.rb | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/pay/lemon_squeezy/billable.rb b/lib/pay/lemon_squeezy/billable.rb index f6706fd9..719c77bb 100644 --- a/lib/pay/lemon_squeezy/billable.rb +++ b/lib/pay/lemon_squeezy/billable.rb @@ -45,24 +45,24 @@ def update_customer!(**attributes) end def charge(amount, options = {}) - # return Pay::Error unless options + # return Pay::Error unless options - # items = options[:items] - # opts = options.except(:items).merge(customer_id: processor_id) - # transaction = ::Paddle::Transaction.create(items: items, **opts) + # items = options[:items] + # opts = options.except(:items).merge(customer_id: processor_id) + # transaction = ::Paddle::Transaction.create(items: items, **opts) - # attrs = { - # amount: transaction.details.totals.grand_total, - # created_at: transaction.created_at, - # currency: transaction.currency_code, - # metadata: transaction.details.line_items&.first&.id - # } + # attrs = { + # amount: transaction.details.totals.grand_total, + # created_at: transaction.created_at, + # currency: transaction.currency_code, + # metadata: transaction.details.line_items&.first&.id + # } - # charge = pay_customer.charges.find_or_initialize_by(processor_id: transaction.id) - # charge.update(attrs) - # charge - # rescue ::Paddle::Error => e - # raise Pay::LemonSqueezy::Error, e + # charge = pay_customer.charges.find_or_initialize_by(processor_id: transaction.id) + # charge.update(attrs) + # charge + # rescue ::Paddle::Error => e + # raise Pay::LemonSqueezy::Error, e end def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options) @@ -81,9 +81,9 @@ def trial_end_date(subscription) end def processor_subscription(subscription_id, options = {}) - # ::Paddle::Subscription.retrieve(id: subscription_id, **options) - # rescue ::Paddle::Error => e - # raise Pay::LemonSqueezy::Error, e + # ::Paddle::Subscription.retrieve(id: subscription_id, **options) + # rescue ::Paddle::Error => e + # raise Pay::LemonSqueezy::Error, e end end end From df7b97086a11aecb4908f10b88bf257950b213d0 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Mon, 29 Jan 2024 21:58:16 +0000 Subject: [PATCH 05/18] LS charges and payment methods --- lib/pay/lemon_squeezy/charge.rb | 56 ++++++++--------------- lib/pay/lemon_squeezy/payment_method.rb | 19 ++++---- lib/pay/lemon_squeezy/webhooks/payment.rb | 2 +- 3 files changed, 28 insertions(+), 49 deletions(-) diff --git a/lib/pay/lemon_squeezy/charge.rb b/lib/pay/lemon_squeezy/charge.rb index ae1eeb1f..0924f204 100644 --- a/lib/pay/lemon_squeezy/charge.rb +++ b/lib/pay/lemon_squeezy/charge.rb @@ -10,57 +10,39 @@ def initialize(pay_charge) end def self.sync(charge_id, object: nil, try: 0, retries: 1) - # Skip loading the latest charge details from the API if we already have it - object ||= ::Paddle::Transaction.retrieve(id: charge_id) + # Skip loading the latest subscription invoice details from the API if we already have it + object ||= ::LemonSqueezy::SubscriptionInvoice.retrieve(id: charge_id) - # Ignore transactions that aren't completed - return unless object.status == "completed" + attrs = object.data.attributes if object.respond_to?(:data) + attrs ||= object # Ignore charges without a Customer - return if object.customer_id.blank? + return if attrs.customer_id.blank? - pay_customer = Pay::Customer.find_by(processor: :paddle_billing, processor_id: object.customer_id) + pay_customer = Pay::Customer.find_by(processor: :lemon_squeezy, processor_id: attrs.customer_id) return unless pay_customer - # Ignore transactions that are payment method changes - # But update the customer's payment method - if object.origin == "subscription_payment_method_change" - Pay::PaddleBilling::PaymentMethod.sync(pay_customer: pay_customer, attributes: object.payments.first) - return - end - - attrs = { - amount: object.details.totals.grand_total, - created_at: object.created_at, - currency: object.currency_code, - metadata: object.details.line_items&.first&.id, - subscription: pay_customer.subscriptions.find_by(processor_id: object.subscription_id) + 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 } - if object.payment - case object.payment.method_details.type.downcase - when "card" - attrs[:payment_method_type] = "card" - attrs[:brand] = details.card.type - attrs[:exp_month] = details.card.expiry_month - attrs[:exp_year] = details.card.expiry_year - attrs[:last4] = details.card.last4 - when "paypal" - attrs[:payment_method_type] = "paypal" - end - - # Update customer's payment method - Pay::PaddleBilling::PaymentMethod.sync(pay_customer: pay_customer, attributes: object.payments.first) - end + # 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: object.id)) + if (pay_charge = pay_customer.charges.find_by(processor_id: charge_id)) pay_charge.with_lock do - pay_charge.update!(attrs) + pay_charge.update!(attributes) end pay_charge else - pay_customer.charges.create!(attrs.merge(processor_id: object.id)) + pay_customer.charges.create!(attributes.merge(processor_id: charge_id)) end end end diff --git a/lib/pay/lemon_squeezy/payment_method.rb b/lib/pay/lemon_squeezy/payment_method.rb index 8bea19b4..57c5b986 100644 --- a/lib/pay/lemon_squeezy/payment_method.rb +++ b/lib/pay/lemon_squeezy/payment_method.rb @@ -6,20 +6,17 @@ class PaymentMethod delegate :customer, :processor_id, to: :pay_payment_method def self.sync(pay_customer:, attributes:) - details = attributes.method_details + 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: details.type.downcase + type: "card", + brand: attributes.card_brand, + last4: attributes.card_last_four } - case details.type.downcase - when "card" - attrs[:brand] = details.card.type - attrs[:last4] = details.card.last4 - attrs[:exp_month] = details.card.expiry_month - attrs[:exp_year] = details.card.expiry_year - end - - payment_method = pay_customer.payment_methods.find_or_initialize_by(processor_id: attributes.stored_payment_method_id) payment_method.update!(attrs) payment_method end diff --git a/lib/pay/lemon_squeezy/webhooks/payment.rb b/lib/pay/lemon_squeezy/webhooks/payment.rb index fe9a7282..7d8ef081 100644 --- a/lib/pay/lemon_squeezy/webhooks/payment.rb +++ b/lib/pay/lemon_squeezy/webhooks/payment.rb @@ -3,7 +3,7 @@ module LemonSqueezy module Webhooks class Payment def call(event) - Pay::LemonSqueezy::Charge.sync(event.id) + Pay::LemonSqueezy::Charge.sync(event.data.id) end end end From 0ee985884b4dfe5159fa44326cb65319d2f6df8f Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Mon, 29 Jan 2024 22:30:00 +0000 Subject: [PATCH 06/18] there's no way to create a charge for LS at the moment so pass --- lib/pay/lemon_squeezy/billable.rb | 33 +++++++------------------------ 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/lib/pay/lemon_squeezy/billable.rb b/lib/pay/lemon_squeezy/billable.rb index 719c77bb..aecc243f 100644 --- a/lib/pay/lemon_squeezy/billable.rb +++ b/lib/pay/lemon_squeezy/billable.rb @@ -45,45 +45,26 @@ def update_customer!(**attributes) end def charge(amount, options = {}) - # return Pay::Error unless options - - # items = options[:items] - # opts = options.except(:items).merge(customer_id: processor_id) - # transaction = ::Paddle::Transaction.create(items: items, **opts) - - # attrs = { - # amount: transaction.details.totals.grand_total, - # created_at: transaction.created_at, - # currency: transaction.currency_code, - # metadata: transaction.details.line_items&.first&.id - # } - - # charge = pay_customer.charges.find_or_initialize_by(processor_id: transaction.id) - # charge.update(attrs) - # charge - # rescue ::Paddle::Error => e - # raise Pay::LemonSqueezy::Error, e + # pass end def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options) # pass end - # Paddle does not use payment method tokens. The method signature has it here - # to have a uniform API with the other payment processors. def add_payment_method(token = nil, default: true) - # Pay::LemonSqueezy::PaymentMethod.sync(pay_customer: pay_customer) + # pass end def trial_end_date(subscription) - # return unless subscription.state == "trialing" - # Time.zone.parse(subscription.next_payment[:date]).end_of_day + return unless subscription.state == "trialing" + Time.zone.parse(subscription.renews_at).end_of_day end def processor_subscription(subscription_id, options = {}) - # ::Paddle::Subscription.retrieve(id: subscription_id, **options) - # rescue ::Paddle::Error => e - # raise Pay::LemonSqueezy::Error, e + ::LemonSqueezy::Subscription.retrieve(id: subscription_id) + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e end end end From 47a63b44667117c6ae20d0f6e2a8df15aea38bea Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Mon, 29 Jan 2024 22:42:02 +0000 Subject: [PATCH 07/18] initial lemon squeezy docs --- README.md | 2 ++ docs/1_installation.md | 3 ++ docs/2_configuration.md | 9 +++++- docs/lemon_squeezy/1_overview.md | 50 ++++++++++++++++++++++++++++++++ docs/lemon_squeezy/3_webhooks.md | 15 ++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 docs/lemon_squeezy/1_overview.md create mode 100644 docs/lemon_squeezy/3_webhooks.md diff --git a/README.md b/README.md index 175833c0..775fff62 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/docs/1_installation.md b/docs/1_installation.md index 429b935c..ce684a0f 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.1" +# 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 67ccca7e..d146d462 100644 --- a/docs/2_configuration.md +++ b/docs/2_configuration.md @@ -38,6 +38,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. @@ -70,6 +74,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` ## Generators @@ -118,7 +125,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/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/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 +``` From 6d3786dc9f47ed096bf4dfb693dca17e23812178 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Mon, 29 Jan 2024 23:06:12 +0000 Subject: [PATCH 08/18] LS customer portal --- lib/pay/lemon_squeezy/billable.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/pay/lemon_squeezy/billable.rb b/lib/pay/lemon_squeezy/billable.rb index aecc243f..8dd75b40 100644 --- a/lib/pay/lemon_squeezy/billable.rb +++ b/lib/pay/lemon_squeezy/billable.rb @@ -61,6 +61,14 @@ def trial_end_date(subscription) Time.zone.parse(subscription.renews_at).end_of_day end + def customer_portal + return unless pay_customer.subscription + sub = ::LemonSqueezy::Subscription.retrieve(id: pay_customer.subscription.processor_id) + sub.urls.customer_portal + rescue ::LemonSqueezy::Error => e + raise Pay::LemonSqueezy::Error, e + end + def processor_subscription(subscription_id, options = {}) ::LemonSqueezy::Subscription.retrieve(id: subscription_id) rescue ::LemonSqueezy::Error => e From bcaf7faae035d1fe8793dd5c82ff05d4e7773efa Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Tue, 30 Jan 2024 08:25:08 +0000 Subject: [PATCH 09/18] additional LS docs --- docs/lemon_squeezy/2_javascript.md | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/lemon_squeezy/2_javascript.md 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. From 57a921fca68cef3b6169504eb56dd8ec06da66e0 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 31 Jan 2024 15:39:43 +0000 Subject: [PATCH 10/18] use `ActiveSupport::SecurityUtils.secure_compare` Co-authored-by: Kyrylo Silin --- app/controllers/pay/webhooks/lemon_squeezy_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/pay/webhooks/lemon_squeezy_controller.rb b/app/controllers/pay/webhooks/lemon_squeezy_controller.rb index cfbd4389..87f4f9ef 100644 --- a/app/controllers/pay/webhooks/lemon_squeezy_controller.rb +++ b/app/controllers/pay/webhooks/lemon_squeezy_controller.rb @@ -34,7 +34,7 @@ def valid_signature?(signature) digest = OpenSSL::Digest.new("sha256") hmac = OpenSSL::HMAC.hexdigest(digest, key, data) - hmac == signature + ActiveSupport::SecurityUtils.secure_compare(hmac, signature) end def verify_params From c13c7bc9f52a52560beccf9a171572ffb1798ae9 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 31 Jan 2024 15:54:22 +0000 Subject: [PATCH 11/18] add LS to enabled_processors otherwise webhooks wont work --- lib/pay.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pay.rb b/lib/pay.rb index 919872f4..75ea209c 100644 --- a/lib/pay.rb +++ b/lib/pay.rb @@ -57,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 From eae3be67fc8ca1e67270fb2a5df079c845a6a976 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 31 Jan 2024 16:09:10 +0000 Subject: [PATCH 12/18] initial LS tests --- lib/pay/lemon_squeezy/billable.rb | 4 +- test/fixtures/pay/customers.yml | 6 + test/fixtures/pay/subscriptions.yml | 8 ++ test/fixtures/users.yml | 6 + test/pay/lemon_squeezy/billable_test.rb | 16 +++ test/pay/lemon_squeezy/error_test.rb | 16 +++ test/pay/lemon_squeezy/subscription_test.rb | 33 +++++ test/support/vcr.rb | 1 + .../test_lemon_squeezy_can_swap_plans.yml | 117 ++++++++++++++++++ ...t_lemon_squeezy_processor_subscription.yml | 60 +++++++++ ...etrieving_a_lemon_squeezy_subscription.yml | 117 ++++++++++++++++++ 11 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 test/pay/lemon_squeezy/billable_test.rb create mode 100644 test/pay/lemon_squeezy/error_test.rb create mode 100644 test/pay/lemon_squeezy/subscription_test.rb create mode 100644 test/vcr_cassettes/test_lemon_squeezy_can_swap_plans.yml create mode 100644 test/vcr_cassettes/test_lemon_squeezy_processor_subscription.yml create mode 100644 test/vcr_cassettes/test_retrieving_a_lemon_squeezy_subscription.yml diff --git a/lib/pay/lemon_squeezy/billable.rb b/lib/pay/lemon_squeezy/billable.rb index 8dd75b40..91886d80 100644 --- a/lib/pay/lemon_squeezy/billable.rb +++ b/lib/pay/lemon_squeezy/billable.rb @@ -45,7 +45,7 @@ def update_customer!(**attributes) end def charge(amount, options = {}) - # pass + raise Pay::Error, "LemonSqueezy does not support one-off charges" end def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options) @@ -57,7 +57,7 @@ def add_payment_method(token = nil, default: true) end def trial_end_date(subscription) - return unless subscription.state == "trialing" + return unless subscription.state == "on-trial" Time.zone.parse(subscription.renews_at).end_of_day 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 From a1c1ab0b86ceb6822504317ca49a0b96f506fa2e Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 31 Jan 2024 16:13:23 +0000 Subject: [PATCH 13/18] update appraisals --- gemfiles/rails_6_1.gemfile | 1 + gemfiles/rails_6_1.gemfile.lock | 3 +++ gemfiles/rails_7.gemfile | 1 + gemfiles/rails_7.gemfile.lock | 3 +++ gemfiles/rails_7_1.gemfile | 1 + gemfiles/rails_7_1.gemfile.lock | 3 +++ gemfiles/rails_main.gemfile | 1 + gemfiles/rails_main.gemfile.lock | 3 +++ 8 files changed, 16 insertions(+) diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile index add89fd8..f5301ab7 100644 --- a/gemfiles/rails_6_1.gemfile +++ b/gemfiles/rails_6_1.gemfile @@ -12,6 +12,7 @@ gem "webmock" gem "braintree", ">= 2.92.0" gem "stripe", "~> 10.4" gem "paddle", "~> 2.1" +gem "lemonsqueezy", "~> 1.0" gem "receipts" gem "prawn", git: "https://github.com/prawnpdf/prawn.git" gem "pg" diff --git a/gemfiles/rails_6_1.gemfile.lock b/gemfiles/rails_6_1.gemfile.lock index 4f4814a4..033d9384 100644 --- a/gemfiles/rails_6_1.gemfile.lock +++ b/gemfiles/rails_6_1.gemfile.lock @@ -116,6 +116,8 @@ GEM iniparse (1.5.0) json (2.7.1) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) loofah (2.22.0) crass (~> 1.0.2) @@ -289,6 +291,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 35bf8987..d23314b9 100644 --- a/gemfiles/rails_7.gemfile +++ b/gemfiles/rails_7.gemfile @@ -12,6 +12,7 @@ gem "webmock" gem "braintree", ">= 2.92.0" gem "stripe", "~> 10.4" gem "paddle", "~> 2.1" +gem "lemonsqueezy", "~> 1.0" gem "receipts" gem "prawn", git: "https://github.com/prawnpdf/prawn.git" gem "pg" diff --git a/gemfiles/rails_7.gemfile.lock b/gemfiles/rails_7.gemfile.lock index de018b5f..d9911ba2 100644 --- a/gemfiles/rails_7.gemfile.lock +++ b/gemfiles/rails_7.gemfile.lock @@ -122,6 +122,8 @@ GEM iniparse (1.5.0) json (2.7.1) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) loofah (2.22.0) crass (~> 1.0.2) @@ -295,6 +297,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 0e4735cf..7cdf59b3 100644 --- a/gemfiles/rails_7_1.gemfile +++ b/gemfiles/rails_7_1.gemfile @@ -12,6 +12,7 @@ gem "webmock" gem "braintree", ">= 2.92.0" gem "stripe", "~> 10.4" gem "paddle", "~> 2.1" +gem "lemonsqueezy", "~> 1.0" gem "receipts" gem "prawn", git: "https://github.com/prawnpdf/prawn.git" gem "pg" diff --git a/gemfiles/rails_7_1.gemfile.lock b/gemfiles/rails_7_1.gemfile.lock index 95651ad1..037c244c 100644 --- a/gemfiles/rails_7_1.gemfile.lock +++ b/gemfiles/rails_7_1.gemfile.lock @@ -140,6 +140,8 @@ GEM reline (>= 0.4.2) json (2.7.1) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.0) + faraday (~> 2.0) lint_roller (1.1.0) loofah (2.22.0) crass (~> 1.0.2) @@ -324,6 +326,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 6f97adc9..4173d359 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -12,6 +12,7 @@ gem "webmock" gem "braintree", ">= 2.92.0" gem "stripe", "~> 10.4" gem "paddle", "~> 2.1" +gem "lemonsqueezy", "~> 1.0" gem "receipts" gem "prawn", git: "https://github.com/prawnpdf/prawn.git" gem "pg" diff --git a/gemfiles/rails_main.gemfile.lock b/gemfiles/rails_main.gemfile.lock index d6d0fc9b..4bd5e0fa 100644 --- a/gemfiles/rails_main.gemfile.lock +++ b/gemfiles/rails_main.gemfile.lock @@ -162,6 +162,8 @@ GEM reline (>= 0.4.2) json (2.7.1) language_server-protocol (3.17.0.3) + lemonsqueezy (1.0.1) + faraday (~> 2.0) lint_roller (1.1.0) loofah (2.22.0) crass (~> 1.0.2) @@ -327,6 +329,7 @@ DEPENDENCIES braintree (>= 2.92.0) byebug importmap-rails + lemonsqueezy (~> 1.0) mocha mysql2 net-imap From 1194bcfc106f39673813317f90f7c9737bc3fcf1 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 31 Jan 2024 16:26:00 +0000 Subject: [PATCH 14/18] remove parse_passthrough --- lib/pay/lemon_squeezy.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/pay/lemon_squeezy.rb b/lib/pay/lemon_squeezy.rb index 1156b3e1..bdd50cd5 100644 --- a/lib/pay/lemon_squeezy.rb +++ b/lib/pay/lemon_squeezy.rb @@ -39,10 +39,6 @@ def self.passthrough(owner:, **options) owner.to_sgid.to_s end - def self.parse_passthrough(passthrough) - JSON.parse(passthrough) - end - def self.owner_from_passthrough(passthrough) GlobalID::Locator.locate_signed passthrough rescue JSON::ParserError From 0edcf1552dc1bf6549ed8a470e3c9efba84277a3 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Wed, 31 Jan 2024 16:48:46 +0000 Subject: [PATCH 15/18] additional LS docs, including renaming customer_portal to portal_url and moving it to the subscription. also added update_url --- docs/3_customers.md | 8 ++++- docs/4_payment_methods.md | 19 ++++++++++++ docs/5_charges.md | 4 +++ docs/6_subscriptions.md | 42 +++++++++++++++++++++++++++ docs/7_webhooks.md | 2 ++ lib/pay/lemon_squeezy/billable.rb | 8 ----- lib/pay/lemon_squeezy/subscription.rb | 14 +++++++++ 7 files changed, 88 insertions(+), 9 deletions(-) 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 f558b707..dbe2a6fa 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 1a6a3b05..ceafcc7d 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 (with yo * **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/lib/pay/lemon_squeezy/billable.rb b/lib/pay/lemon_squeezy/billable.rb index 91886d80..4642d928 100644 --- a/lib/pay/lemon_squeezy/billable.rb +++ b/lib/pay/lemon_squeezy/billable.rb @@ -61,14 +61,6 @@ def trial_end_date(subscription) Time.zone.parse(subscription.renews_at).end_of_day end - def customer_portal - return unless pay_customer.subscription - sub = ::LemonSqueezy::Subscription.retrieve(id: pay_customer.subscription.processor_id) - sub.urls.customer_portal - rescue ::LemonSqueezy::Error => e - raise Pay::LemonSqueezy::Error, e - end - def processor_subscription(subscription_id, options = {}) ::LemonSqueezy::Subscription.retrieve(id: subscription_id) rescue ::LemonSqueezy::Error => e diff --git a/lib/pay/lemon_squeezy/subscription.rb b/lib/pay/lemon_squeezy/subscription.rb index 2ccd69af..39e370af 100644 --- a/lib/pay/lemon_squeezy/subscription.rb +++ b/lib/pay/lemon_squeezy/subscription.rb @@ -88,6 +88,20 @@ 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) From ff422f2d39ab89acb76e38c9b7b0f3f90390dcfb Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Tue, 23 Apr 2024 13:21:31 +0100 Subject: [PATCH 16/18] remove lemon squeezy passthrough and update docs --- docs/6_subscriptions.md | 24 ++++++------------------ docs/lemon_squeezy/2_javascript.md | 8 ++++---- lib/pay/lemon_squeezy.rb | 10 ---------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/docs/6_subscriptions.md b/docs/6_subscriptions.md index 7a66a42d..68848699 100644 --- a/docs/6_subscriptions.md +++ b/docs/6_subscriptions.md @@ -126,36 +126,24 @@ Lemon Squeezy dashboard, clicking the "Share" button will provide you with the U For example, the hosted checkout flow: ```html -https://STORE.lemonsqueezy.com/checkout/buy/UUID +https://STORE.lemonsqueezy.com/checkout/buy/UUID?checkout[email]=myemail@gmail.com ``` And the checkout overlay flow: ```html -Buy A Product +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'll need to replace `STORE` 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. + +Passing the customer's email address to the checkout URL will link it to the customer created in Lemon Squeezy. 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 diff --git a/docs/lemon_squeezy/2_javascript.md b/docs/lemon_squeezy/2_javascript.md index ded5314e..a31d4c7f 100644 --- a/docs/lemon_squeezy/2_javascript.md +++ b/docs/lemon_squeezy/2_javascript.md @@ -19,15 +19,15 @@ 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 +You'll need to replace `STORE` 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. +Passing the customer's email address to the checkout URL will link it to the customer created in Lemon Squeezy. + ```html + href="https://STORE.lemonsqueezy.com/checkout/buy/UUID?checkout[email]=<%= CGI.escape(@user.email) %>"> Sign up to Plan ``` diff --git a/lib/pay/lemon_squeezy.rb b/lib/pay/lemon_squeezy.rb index bdd50cd5..cb291ad4 100644 --- a/lib/pay/lemon_squeezy.rb +++ b/lib/pay/lemon_squeezy.rb @@ -35,16 +35,6 @@ 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 From fe8c0b6c7f6ac5c82542e0cecc007c8cd5cafd0a Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Tue, 18 Jun 2024 10:43:42 +0100 Subject: [PATCH 17/18] Revert "remove lemon squeezy passthrough and update docs" This reverts commit ff422f2d39ab89acb76e38c9b7b0f3f90390dcfb. --- docs/6_subscriptions.md | 24 ++++++++++++++++++------ docs/lemon_squeezy/2_javascript.md | 8 ++++---- lib/pay/lemon_squeezy.rb | 10 ++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/6_subscriptions.md b/docs/6_subscriptions.md index 68848699..7a66a42d 100644 --- a/docs/6_subscriptions.md +++ b/docs/6_subscriptions.md @@ -126,24 +126,36 @@ Lemon Squeezy dashboard, clicking the "Share" button will provide you with the U For example, the hosted checkout flow: ```html -https://STORE.lemonsqueezy.com/checkout/buy/UUID?checkout[email]=myemail@gmail.com +https://STORE.lemonsqueezy.com/checkout/buy/UUID ``` And the checkout overlay flow: ```html -Buy A Product +Buy A Product ``` -You'll need to replace `STORE` 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. - -Passing the customer's email address to the checkout URL will link it to the customer created in Lemon Squeezy. +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 diff --git a/docs/lemon_squeezy/2_javascript.md b/docs/lemon_squeezy/2_javascript.md index a31d4c7f..ded5314e 100644 --- a/docs/lemon_squeezy/2_javascript.md +++ b/docs/lemon_squeezy/2_javascript.md @@ -19,15 +19,15 @@ 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'll need to replace `STORE` 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. +You can use the `Pay::LemonSqueezy.passthrough` helper to generate the `checkout[custom][passthrough]` field. -Passing the customer's email address to the checkout URL will link it to the customer created in Lemon Squeezy. +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 + href="https://storename.lemonsqueezy.com/checkout/buy/UUID?checkout[email]=<%= @user.email %>&checkout[custom][passthrough]=<%= Pay::LemonSqueezy.passthrough(owner: @user) %>"> Sign up to Plan ``` diff --git a/lib/pay/lemon_squeezy.rb b/lib/pay/lemon_squeezy.rb index cb291ad4..bdd50cd5 100644 --- a/lib/pay/lemon_squeezy.rb +++ b/lib/pay/lemon_squeezy.rb @@ -35,6 +35,16 @@ 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 From eb3b906bf641dd95882e144c03383fccc6c10f29 Mon Sep 17 00:00:00 2001 From: Dean Perry Date: Tue, 18 Jun 2024 10:45:30 +0100 Subject: [PATCH 18/18] remove sync_from_transaction from lemon squeezy subscription as it was copied from paddle --- lib/pay/lemon_squeezy/subscription.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/pay/lemon_squeezy/subscription.rb b/lib/pay/lemon_squeezy/subscription.rb index 39e370af..15ca78e4 100644 --- a/lib/pay/lemon_squeezy/subscription.rb +++ b/lib/pay/lemon_squeezy/subscription.rb @@ -22,11 +22,6 @@ class Subscription :trial_ends_at, to: :pay_subscription - def self.sync_from_transaction(transaction_id) - transaction = ::Paddle::Transaction.retrieve(id: transaction_id) - sync(transaction.subscription_id) if transaction.subscription_id - end - 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)