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

Paddle payment provider #165

Merged
merged 14 commits into from
Nov 18, 2020
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,14 @@ GEM
crack (0.4.4)
crass (1.0.6)
erubi (1.9.0)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
globalid (0.4.2)
activesupport (>= 4.2.0)
hashdiff (1.0.1)
i18n (1.8.5)
concurrent-ruby (~> 1.0)
json (2.3.0)
libxml-ruby (3.2.1)
loofah (2.7.0)
crass (~> 1.0.2)
Expand All @@ -98,9 +101,13 @@ GEM
minitest (~> 5.10)
railties (~> 6.0.0)
mocha (1.11.2)
multipart-post (2.1.1)
nio4r (2.5.4)
nokogiri (1.10.10)
mini_portile2 (~> 2.4.0)
paddle_pay (0.0.1)
faraday (~> 1.0)
json (~> 2.0)
parallel (1.19.2)
parser (2.7.2.0)
ast (~> 2.4.1)
Expand Down Expand Up @@ -214,6 +221,7 @@ DEPENDENCIES
byebug
minitest-rails (~> 6)
mocha
paddle_pay (~> 0.0.1)
pay!
pry
puma
Expand Down
132 changes: 125 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Pay is a payments engine for Ruby on Rails 4.2 and higher.

- Stripe ([supports SCA](https://stripe.com/docs/strong-customer-authentication) using API version `2020-08-27`)
- Braintree
- Paddle

Want to add a new payment provider? Contributions are welcome and the instructions [are here](https://github.com/jasoncharnes/pay/wiki/New-Payment-Provider).

Expand All @@ -35,6 +36,9 @@ gem 'stripe_event', '~> 2.3'
# To use Braintree + PayPal, also include:
gem 'braintree', '< 3.0', '>= 2.92.0'

# To use Paddle, also include:
gem 'paddle_pay', '~> 0.0.1'

# To use Receipts
gem 'receipts', '~> 1.0.0'
```
Expand Down Expand Up @@ -133,10 +137,15 @@ development:
public_key: yyyy
merchant_id: aaaa
environment: sandbox
paddle:
vendor_id: xxxx
vendor_auth_code: yyyy
public_key_base64: MII...==
```

For Stripe, you can also use the `STRIPE_PUBLIC_KEY`, `STRIPE_PRIVATE_KEY` and `STRIPE_SIGNING_SECRET` environment variables.
For Braintree, you can also use `BRAINTREE_MERCHANT_ID`, `BRAINTREE_PUBLIC_KEY`, `BRAINTREE_PRIVATE_KEY`, and `BRAINTREE_ENVIRONMENT` environment variables.
For Paddle, you can also use `PADDLE_VENDOR_ID`, `PADDLE_VENDOR_AUTH_CODE` and `PADDLE_PUBLIC_KEY_BASE64` environment variables.

### Generators

Expand Down Expand Up @@ -196,6 +205,8 @@ user.on_generic_trial? #=> true

#### Creating a Charge

##### Stripe and Braintree

```ruby
user = User.find_by(email: 'michael@bluthcompany.co')

Expand All @@ -219,8 +230,23 @@ different currencies, etc.
On failure, a `Pay::Error` will be raised with details about the payment
failure.

##### Paddle
It is only possible to create immediate one-time charges on top of an existing subscription.

```ruby
user = User.find_by(email: 'michael@bluthcompany.co')

user.processor = 'paddle'
user.charge(1500, {charge_name: "Test"}) # $15.00 USD

```

An existing subscription and a charge name are required.

#### Creating a Subscription

##### Stripe and Braintree

```ruby
user = User.find_by(email: 'michael@bluthcompany.co')

Expand All @@ -246,20 +272,41 @@ For example, you can pass the `quantity` option to subscribe to a plan with for
user.subscribe(name: "default", plan: "default", quantity: 3)
```

##### Name
###### Name

Name is an internally used name for the subscription.

##### Plan
###### Plan

Plan is the plan ID or price ID from the payment processor. For example: `plan_xxxxx` or `price_xxxxx`

##### Options
###### Options

By default, the trial specified on the subscription will be used.

`trial_period_days: 30` can be set to override and a trial to the subscription. This works the same for Braintree and Stripe.

##### Paddle
It is currently not possible to create a subscription through the API. Instead the subscription in Pay is created by the Paddle Subscription Webhook. In order to be able to assign the subcription to the correct owner, the Paddle [passthrough parameter](https://developer.paddle.com/guides/how-tos/checkout/pass-parameters) has to be used for checkout.

To ensure that the owner cannot be tampered with, Pay uses a Signed Global ID with a purpose. The purpose string consists of "paddle_" and the subscription plan id (or product id respectively).

Javascript Checkout:
```javascript
Paddle.Checkout.open({
product: 12345,
passthrough: "{\"owner_sgid\": \"<%= current_user.to_sgid(for: 'paddle_12345') %>\"}"
});
```
Copy link
Contributor

Choose a reason for hiding this comment

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

Alternatively, if using the paddle button:

<a href="#!" class="paddle_button" data-product="12345" data-email="<%= current_user.email %>" data-passthrough="<%= { owner_id: current_user.id, owner_type: "User" }.to_json %>"

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this secure? I could change the model and ID to anything.

Seems like we should do a signed GlobalID or something instead that we could verify server-side.

Copy link
Contributor Author

@nm nm Nov 17, 2020

Choose a reason for hiding this comment

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

Yes, you're right. That would allow to pay for another owner id or owner type. I changed it and now a signed GlobalID is used. In addition, a purpose string ensures that GlobalIDs cannot be swapped between Paddle products.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Since this is received by the webhook for the subscription, I think the for: is probably unnecessary?

If so, we can remove it and simplify a little bit which is nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It depends: If you can create subscriptions for multiple owner types in your app or signed global IDs are made public otherwise, the for: would be necessary to prevent swapping the SGID in the javascript code.

Paddle.Checkout.open({
	product: 12345,
	passthrough: "{\"owner_sgid\": \"<%= modelA.to_sgid.to_s %>\"}"
});
Paddle.Checkout.open({
	product: 67890,
	passthrough: "{\"owner_sgid\": \"<%= modelB.to_sgid.to_s %>\"}"
});

How likely is such a scenario? Probably not very likely. To be honest: If I pay for a subscription I am not interesting in changing the owner. That is the reason I just used owner_type and owner_id in the beginning. I don't know, maybe using SGIDs without for: is a good tradeoff between simplicity and security. What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

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

At least with an SGID, they could only swap SGIDs they had access to since it'd have to be generated server side. That'd leave it only to accounts they have access to.

If it was clear text, it's easy to tamper with. To be fair, you wouldn't want to pay for something and get it assigned to someone else, so it seems illogical to tamper with.

Maybe I'll build a helper to generate the passthrough JSON.


Paddle Button Checkout:
```html
<a href="#!" class="paddle_button" data-product="12345" data-email="<%= current_user.email %>" data-passthrough="<%= { owner_sgid: current_user.to_sgid(for: 'paddle_12345') }.to_json %>"
```

Pay parses the passthrough JSON string and verifies the `owner_sgid` hash.
The passthrough parameter `owner_sgid` is only required for creating a subscription.

#### Retrieving a Subscription from the Database

```ruby
Expand Down Expand Up @@ -316,26 +363,45 @@ Plan is the plan ID from the payment processor.

#### Retrieving a Payment Processor Account

##### Stripe and Braintree

```ruby
user = User.find_by(email: 'george.michael@bluthcompany.co')

user.customer #> Stripe or Braintree customer account
```

##### Paddle

It is currently not possible to retrieve a payment processor account through the API.

#### Updating a Customer's Credit Card

##### Stripe and Braintree

```ruby
user = User.find_by(email: 'tobias@bluthcompany.co')

user.update_card('payment_method_id')
```

##### Paddle

Paddle provides a unique [Update URL](https://developer.paddle.com/guides/how-tos/subscriptions/update-payment-details) for each user, which allows them to update the payment method.
```ruby
user = User.find_by(email: 'tobias@bluthcompany.co')

user.subscription.update_url
```



#### Retrieving a Customer's Subscription from the Processor

```ruby
user = User.find_by(email: 'lucille@bluthcompany.co')

user.processor_subscription(subscription_id) #=> Stripe or Braintree Subscription
user.processor_subscription(subscription_id) #=> Stripe, Braintree or Paddle Subscription
```

## Subscription API
Expand Down Expand Up @@ -372,14 +438,31 @@ user = User.find_by(email: 'carl.weathers@bluthcompany.co')
user.subscription.active? #=> true or false
```

#### Checking to See If a Subscription Is Paused

```ruby
user = User.find_by(email: 'carl.weathers@bluthcompany.co')

user.subscription.paused? #=> true or false
```

#### Cancel a Subscription (At End of Billing Cycle)

##### Stripe, Braintree and Paddle

```ruby
user = User.find_by(email: 'oscar@bluthcompany.co')

user.subscription.cancel
```

##### Paddle
In addition to the API, Paddle provides a subscription [Cancel URL](https://developer.paddle.com/guides/how-tos/subscriptions/cancel-and-pause) that you can redirect customers to cancel their subscription.

```ruby
user.subscription.cancel_url
```

#### Cancel a Subscription Immediately

```ruby
Expand All @@ -388,6 +471,16 @@ user = User.find_by(email: 'annyong@bluthcompany.co')
user.subscription.cancel_now!
```

#### Pause a Subscription

##### Paddle

```ruby
user = User.find_by(email: 'oscar@bluthcompany.co')

user.subscription.pause
```

#### Swap a Subscription to another Plan

```ruby
Expand All @@ -396,7 +489,17 @@ user = User.find_by(email: 'steve.holt@bluthcompany.co')
user.subscription.swap("yearly")
```

#### Resume a Subscription on a Grace Period
#### Resume a Subscription

##### Stripe or Braintree Subscription (on Grace Period)

```ruby
user = User.find_by(email: 'steve.holt@bluthcompany.co')

user.subscription.resume
```

##### Paddle (Paused)

```ruby
user = User.find_by(email: 'steve.holt@bluthcompany.co')
Expand Down Expand Up @@ -473,8 +576,8 @@ config.routes_path = '/secret-webhook-path'

## Payment Providers

We support both Stripe and Braintree and make our best attempt to
standardize the two. They function differently so keep that in mind if
We support Stripe, Braintree and Paddle and make our best attempt to
standardize the three. They function differently so keep that in mind if
you plan on doing more complex payments. It would be best to stick with
a single payment provider in that case so you don't run into
discrepancies.
Expand All @@ -489,7 +592,22 @@ development:
merchant_id: zzzz
environment: sandbox
```
#### Paddle

```yaml
paddle:
vendor_id: xxxx
vendor_auth_code: yyyy
public_key_base64: MII...==
```

Paddle receipts can be retrieved by a charge receipt URL.
```ruby
user = User.find_by(email: 'annyong@bluthcompany.co')

charge = user.charges.first
charge.receipt_url
```
#### Stripe

You'll need to add your private Stripe API key to your Rails secrets `config/secrets.yml`, credentials `rails credentials:edit`
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/pay/webhooks/paddle_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module Pay
module Webhooks
class PaddleController < Pay::ApplicationController
if Rails.application.config.action_controller.default_protect_from_forgery
skip_before_action :verify_authenticity_token
end

def create
verifier = Pay::Paddle::Webhooks::SignatureVerifier.new(check_params.as_json)
if verifier.verify
case params["alert_name"]
when "subscription_created"
Pay::Paddle::Webhooks::SubscriptionCreated.new(check_params.as_json)
when "subscription_updated"
Pay::Paddle::Webhooks::SubscriptionUpdated.new(check_params.as_json)
when "subscription_cancelled"
Pay::Paddle::Webhooks::SubscriptionCancelled.new(check_params.as_json)
when "subscription_payment_succeeded"
Pay::Paddle::Webhooks::SubscriptionPaymentSucceeded.new(check_params.as_json)
when "subscription_payment_refunded"
Pay::Paddle::Webhooks::SubscriptionPaymentRefunded.new(check_params.as_json)
end
render json: {success: true}, status: :ok
excid3 marked this conversation as resolved.
Show resolved Hide resolved
else
head :ok
end
end

private

def check_params
params.except(:action, :controller).permit!
end
end
end
end
4 changes: 4 additions & 0 deletions app/models/pay/charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,9 @@ def braintree?
def paypal?
braintree? && card_type == "PayPal"
end

def paddle?
processor == "paddle"
end
end
end
24 changes: 21 additions & 3 deletions app/models/pay/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module Pay
class Subscription < ApplicationRecord
self.table_name = Pay.subscription_table

STATUSES = %w[incomplete incomplete_expired trialing active past_due canceled unpaid]
STATUSES = %w[incomplete incomplete_expired trialing active past_due canceled unpaid paused]

# Associations
belongs_to :owner, polymorphic: true
Expand Down Expand Up @@ -66,6 +66,15 @@ def has_incomplete_payment?
past_due? || incomplete?
end

def paused?
status == "paused"
end

def pause
return unless paddle?
send("#{processor}_pause")
end

def cancel
send("#{processor}_cancel")
end
Expand All @@ -76,8 +85,17 @@ def cancel_now!

def resume
unless on_grace_period?
raise StandardError,
"You can only resume subscriptions within their grace period."
unless paddle?
raise StandardError,
"You can only resume subscriptions within their grace period."
end
end

unless paused?
if paddle?
raise StandardError,
"You can only resume paused subscriptions."
end
end

send("#{processor}_resume")
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
resources :payments, only: [:show], module: :pay
post "webhooks/stripe", to: "stripe_event/webhook#event"
post "webhooks/braintree", to: "pay/webhooks/braintree#create"
post "webhooks/paddle", to: "pay/webhooks/paddle#create"
end