Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lemon Squeezy support #947

Merged
merged 21 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ gem "webmock"
gem "braintree", ">= 2.92.0"
gem "stripe", "~> 11.0"
gem "paddle", "~> 2.2"
gem "lemonsqueezy", "~> 1.0"

gem "receipts"
gem "prawn"
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ GEM
reline (>= 0.4.2)
json (2.7.2)
language_server-protocol (3.17.0.3)
lemonsqueezy (1.0.0)
faraday (~> 2.0)
lint_roller (1.1.0)
loofah (2.22.0)
crass (~> 1.0.2)
Expand Down Expand Up @@ -325,6 +327,7 @@ DEPENDENCIES
braintree (>= 2.92.0)
byebug
importmap-rails
lemonsqueezy (~> 1.0)
mocha
mysql2
net-imap
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions app/controllers/pay/webhooks/lemon_squeezy_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Pay
module Webhooks
class LemonSqueezyController < Pay::ApplicationController
if Rails.application.config.action_controller.default_protect_from_forgery
skip_before_action :verify_authenticity_token
end

def create
if valid_signature?(request.headers["X-Signature"])
queue_event(verify_params.as_json)
head :ok
else
head :bad_request
end
rescue Pay::LemonSqueezy::Error
head :bad_request
end

private

def queue_event(event)
return unless Pay::Webhooks.delegator.listening?("lemon_squeezy.#{params[:meta][:event_name]}")

record = Pay::Webhook.create!(processor: :lemon_squeezy, event_type: params[:meta][:event_name], event: event)
Pay::Webhooks::ProcessJob.perform_later(record)
end

# Pass Lemon Squeezy signature from request.headers["X-Signature"]
def valid_signature?(signature)
return false if signature.blank?

key = Pay::LemonSqueezy.signing_secret
data = request.raw_post
digest = OpenSSL::Digest.new("sha256")

hmac = OpenSSL::HMAC.hexdigest(digest, key, data)
ActiveSupport::SecurityUtils.secure_compare(hmac, signature)
end

def verify_params
params.except(:action, :controller).permit!
end
end
end
end
2 changes: 1 addition & 1 deletion app/models/pay/charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion app/models/pay/webhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def process!
Pay::Webhooks.instrument type: "#{processor}.#{event_type}", event: rehydrated_event

# Remove after successfully processing
destroy
# destroy
Copy link

@nflorentin nflorentin Aug 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering why the addition of lemon_squeezy is commenting this line of code which seems to impact all processors.

end

# Events have already been verified by the webhook, so we just store the raw data
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions docs/1_installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Expand Down
9 changes: 8 additions & 1 deletion docs/2_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/3_customers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
#=> #<Stripe::Customer>
#=> #<Paddle::Customer>
#=> #<LemonSqueezy::Customer>
```

##### Paddle Classic:
Expand Down
19 changes: 19 additions & 0 deletions docs/4_payment_methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/5_charges.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions docs/6_subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,48 @@ Or with Paddle Button Checkout:
</a>
```

##### 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
<a href="https://STORE.lemonsqueezy.com/checkout/buy/UUID?embed=1" class="lemonsqueezy-button">Buy A Product</a>
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
```

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
<a
class="lemonsqueezy-button"
href="https://storename.lemonsqueezy.com/checkout/buy/UUID?checkout[custom][passthrough]=<%= Pay::LemonSqueezy.passthrough(owner: @user) %>">
Sign up to Plan
</a>
```

## Retrieving a Subscription from the Database

```ruby
Expand Down
2 changes: 2 additions & 0 deletions docs/7_webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docs/lemon_squeezy/1_overview.md
Original file line number Diff line number Diff line change
@@ -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`
Loading
Loading