Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 26 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,40 @@

### Unreleased

* Webhook controllers share `Pay::Webhooks::BaseController`; each processor now only implements signature verification and event type. A malformed `Paddle-Signature` header and a missing Lemon Squeezy signing secret respond 400 instead of raising
* Braintree subscription webhooks share one `Pay::Braintree::Webhooks::Subscription` handler; the named handler classes remain as subclasses. The no-op `subscription_charged_unsuccessfully` handler is removed
* The Stripe SCA confirmation page passes the publishable key, Connect account, and messages to its Stimulus controller as values, so the script contains no ERB and a translation with a quote can't break it. The `back` parameter is validated with Rails' `url_from`, which keeps a same-site query string and no longer raises on a malformed value. Regenerate the view with `rails g pay:views` if you have customized it
* Remove the `processor_id` uniqueness validations from `Pay::Customer`, `Pay::Charge`, `Pay::Subscription`, and `Pay::PaymentMethod`. The unique indexes have enforced this since Pay 3; the validations only added a query to every save. A duplicate now raises `ActiveRecord::RecordNotUnique`, which the sync retries already handle
* [Breaking] Removed public methods that Pay never called and never documented: `Pay::Subscription#skip_trial`, `#has_trial?`, `Pay::Subscription::STATUSES`, the `cancelled` scope (the `cancelled?` predicate stays), `Pay::Charge.sorted`, `Pay::Customer.not_fake_processor`, `Pay::PaymentMethod.pay_processor_for`, `Pay::Currency#subunit?`, `Pay::Receipts#filename`, `Pay::Payment#payment_intent?`, `#setup_intent?` and `#cancelled?`, `Pay::Stripe::Subscription.sync_from_checkout_session` (use `Pay::Stripe.sync_checkout_session`), `Pay::PaddleBilling::Subscription.sync_from_transaction`, `Pay::LemonSqueezy.owner_from_passthrough`, and the empty `retry_failed_payment` on Paddle Billing and Paddle Classic subscriptions
* [Breaking] Operations a processor cannot perform raise `NotImplementedError` consistently: Lemon Squeezy `charge` and `cancel_now!` (previously `Pay::Error`), and Paddle Billing and Paddle Classic `subscribe` (previously returned nil silently)
* Documented `checkout_charge`, `customer_session`, and `preview_invoice` for Stripe
#### Breaking changes

See the [UPGRADE guide](./UPGRADE.md#pay-120) for each of these.

* Removed public methods that Pay never called and never documented: `Pay::Subscription#skip_trial`, `#has_trial?`, `Pay::Subscription::STATUSES`, `Pay::Customer.not_fake_processor`, `Pay::PaymentMethod.pay_processor_for`, `Pay::Currency#subunit?`, `Pay::Receipts#filename`, `Pay::Payment#payment_intent?`, `#setup_intent?` and `#cancelled?`, `Pay::LemonSqueezy.owner_from_passthrough`, and the empty `retry_failed_payment` on Paddle Billing and Paddle Classic subscriptions
* Operations a processor cannot perform raise `Pay::NotSupportedError`, a subclass of `Pay::Error`: Lemon Squeezy `charge` and `cancel_now!` (previously `Pay::Error`), Paddle Billing and Paddle Classic `subscribe` (previously returned nil silently), and Braintree `pause` and `change_quantity` and Paddle Classic `change_quantity` (previously `NotImplementedError`)
* Every Stripe method that calls the API now raises `Pay::Stripe::Error` on a Stripe failure, as the docs promise. Previously 20 of them (`checkout`, `billing_portal`, `pause`, `invoice!`, the `sync` methods, `Pay::Payment.from_id`, and others) let the raw `Stripe::StripeError` through
* Lemon Squeezy subscriptions now sync `on_trial` as `trialing` and `cancelled` as `canceled`, so they answer `active?` correctly, and store the pause end in `pause_resumes_at` instead of `pause_starts_at`. `resume` unpauses paused subscriptions instead of uncancelling them. Existing rows need a one-time update
* `Pay::LemonSqueezy::Charge` no longer overrides ActiveRecord `save` with an API fetch; `Pay::LemonSqueezy::Charge.sync("order:123")` and `sync!` now work like the other processors
* Removed the `processor_id` uniqueness validations from `Pay::Customer`, `Pay::Charge`, `Pay::Subscription`, and `Pay::PaymentMethod`. The unique indexes have enforced this since Pay 3; a duplicate now raises `ActiveRecord::RecordNotUnique` instead of `RecordInvalid`
* `retry_past_due_subscriptions!` moved from `Pay::Customer` to `Pay::Stripe::Customer`; it relies on `pay_open_invoices`, which only Stripe supports
* Webhook controllers share `Pay::Webhooks::BaseController`; each processor now only implements `verified_event` and `event_type`. The no-op `Pay::Braintree::Webhooks::SubscriptionChargedUnsuccessfully` handler is removed

#### Deprecations

* `Pay::Stripe::Subscription.sync_from_checkout_session` is deprecated in favor of `Pay::Stripe.sync_checkout_session`, and `Pay::PaddleBilling::Subscription.sync_from_transaction` in favor of `Pay::PaddleBilling.sync_transaction`. Both will be removed in Pay 13

#### Fixes

* `Pay::Stripe::Charge.sync`, `Subscription.sync`, and `PaymentMethod.sync` share one retry and customer lookup via `Pay::Stripe::Sync`. A retry now re-reads the object from Stripe when the caller didn't pass one in (previously it reused the first read), and all three use the same growing delay. The internal `try:` keyword and the debug log lines for a missing customer are removed
* `Pay::Stripe::Charge.sync`, `Subscription.sync`, and `PaymentMethod.sync` share one retry and customer lookup via `Pay::Sync`, which the Braintree, Paddle Billing, Paddle Classic, and Lemon Squeezy syncs now use too. Paddle Classic and Lemon Squeezy gained the retry, and Braintree's payment method sync declared one it never had. A retry now re-reads the object from Stripe when the caller didn't pass one in (previously it reused the first read), and all three use the same growing delay. The internal `try:` keyword and the debug log lines for a missing customer are removed
* Fix `Pay::Customer#has_incomplete_payment?`, which combined the `active` and `incomplete` scopes and could never return true
* Fix `Pay::Merchant#onboarding_complete?` raising `KeyError` when `data` holds other keys
* `Pay::PaddleBilling::Error`, `Pay::PaddleClassic::Error`, and `Pay::LemonSqueezy::Error` no longer raise from `#message` when raised with a string
* `Pay::LemonSqueezy::Charge` no longer overrides ActiveRecord `save` with an API fetch; `Pay::LemonSqueezy::Charge.sync("order:123")` and `sync!` now work like the other processors
* Fix canceled Paddle Billing and Lemon Squeezy subscriptions not removing the customer's payment methods (the lookup compared the processor's customer ID to Pay's integer foreign key)
* Lemon Squeezy subscriptions now sync `on_trial` as `trialing` and `cancelled` as `canceled`, so they answer `active?` correctly, and store the pause end in `pause_resumes_at` instead of `pause_starts_at`. `resume` unpauses paused subscriptions instead of uncancelling them
* Fix `Pay::Stripe::Subscription#retry_failed_payment` and `Pay::Stripe::PaymentMethod#detach` sending the Connect account as a request parameter instead of a request option
* `Pay::Stripe::Subscription#swap(prorate: false)` no longer forwards the removed `prorate` parameter to Stripe
* `retry_past_due_subscriptions!` moved from `Pay::Customer` to `Pay::Stripe::Customer`; it relies on `pay_open_invoices`, which only Stripe supports
* `Pay.mailer` is resolved on every call instead of memoizing the class, so code reloading in development no longer leaves it pointing at a stale mailer
* A malformed `Paddle-Signature` header and a missing Lemon Squeezy signing secret respond 400 instead of raising

#### Improvements

* `Charge.sync`, `Subscription.sync`, and `PaymentMethod.sync` on every processor share one retry and customer lookup via `Pay::Sync`. A retry now re-reads the object from the processor when the caller didn't pass one in (previously it reused the first read), all processors use the same growing delay, and Paddle Classic and Lemon Squeezy gained the retry. The internal `try:` keyword and the debug log lines for a missing customer are removed
* Braintree subscription webhooks share one `Pay::Braintree::Webhooks::Subscription` handler; the named handler classes remain as subclasses
* The Stripe SCA confirmation page passes the publishable key, Connect account, and messages to its Stimulus controller as values, so the script contains no ERB and a translation with a quote can't break it. The `back` parameter is validated with Rails' `url_from`, which keeps a same-site query string and no longer raises on a malformed value. Regenerate the view with `rails g pay:views` if you have customized it
* Documented `checkout_charge`, `customer_session`, and `preview_invoice` for Stripe

### 11.8.0

Expand Down
62 changes: 61 additions & 1 deletion UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,67 @@

Follow this guide to upgrade older Pay versions. These may require database migrations and code changes.

## Pay 11.9
## Pay 12.0

### Removed methods

These had no callers in Pay and were never documented. Each has a replacement or was already covered by another method.

| Removed | Use instead |
|---|---|
| `Pay::Subscription#has_trial?` | `trial_ends_at?` |
| `Pay::Subscription#skip_trial` | `subscription.trial_ends_at = nil` |
| `Pay::Subscription::STATUSES` | Not needed; statuses are validated by the processors |
| `Pay::Customer.not_fake_processor` | `where.not(processor: :fake_processor)` |
| `Pay::PaymentMethod.pay_processor_for` | `"Pay::#{name.classify}::PaymentMethod".constantize` |
| `Pay::Currency#subunit?` | `subunit.present?` (the removed method returned the opposite) |
| `Pay::Receipts#filename` | `receipt_filename` |
| `Pay::Payment#payment_intent?`, `#setup_intent?` | `intent.is_a?(::Stripe::PaymentIntent)` / `::Stripe::SetupIntent` |
| `Pay::Payment#cancelled?` | `canceled?` |
| `Pay::LemonSqueezy.owner_from_passthrough` | `GlobalID::Locator.locate_signed(passthrough)` |
| `retry_failed_payment` on Paddle Billing and Paddle Classic subscriptions | These were empty; Paddle handles retries itself |

### Deprecated methods

These still work but log a deprecation warning, and will be removed in Pay 13.

| Deprecated | Use instead |
|---|---|
| `Pay::Stripe::Subscription.sync_from_checkout_session(session_id)` | `Pay::Stripe.sync_checkout_session(session_id)`, which also syncs one-time payments and retries while Stripe attaches the subscription |
| `Pay::PaddleBilling::Subscription.sync_from_transaction(transaction_id)` | `Pay::PaddleBilling.sync_transaction(transaction_id)`, which also syncs one-time charges |

### Unsupported operations raise `Pay::NotSupportedError`

Calling something a processor cannot do now raises `Pay::NotSupportedError` with the processor named. It is a subclass of `Pay::Error`, so existing `rescue Pay::Error` blocks still catch it.

| Operation | Previously |
|---|---|
| Lemon Squeezy `charge` and `cancel_now!` | `Pay::Error` |
| Paddle Billing and Paddle Classic `subscribe` | Returned `nil` silently |
| Braintree `pause` and `change_quantity`, Paddle Classic `change_quantity` | `NotImplementedError` |

If you rescued `NotImplementedError` around the Braintree or Paddle Classic calls, rescue `Pay::NotSupportedError` instead.

### Stripe errors are always `Pay::Stripe::Error`

Every Stripe method that calls the API now raises `Pay::Stripe::Error` when Stripe fails. Before, about twenty methods let the raw `Stripe::StripeError` through, including `checkout`, `billing_portal`, `pause`, `invoice!`, the `sync` methods, and `Pay::Payment.from_id`. If you have `rescue Stripe::StripeError` around any of those, change it to `rescue Pay::Error`. The original Stripe error is available as `error.cause`.

### Duplicates raise `ActiveRecord::RecordNotUnique`

The `processor_id` uniqueness validations on `Pay::Customer`, `Pay::Charge`, `Pay::Subscription`, and `Pay::PaymentMethod` are gone; the unique indexes that have existed since Pay 3 enforce it. If you create these records yourself and rescued `ActiveRecord::RecordInvalid` for a duplicate, rescue `ActiveRecord::RecordNotUnique` instead. Pay's own syncs already handle both.

### Webhook controllers

The five webhook controllers now inherit from `Pay::Webhooks::BaseController`. If you subclass or override one, the extension points are `verified_event`, which returns the event or raises a `Pay::Error` subclass on a bad signature, and `event_type(event)`. The private `verify_params` and the old `queue_event(event)` signature are gone.

`Pay::Braintree::Webhooks::SubscriptionChargedUnsuccessfully` was an empty handler and is removed. If you subscribed your own handler to `braintree.subscription_charged_unsuccessfully`, it keeps working.

### Stripe SCA confirmation page

If you copied the payment views with `rails g pay:views`, regenerate them. The page's Stripe.js setup moved from an inline `window.stripe = Stripe(...)` into the Stimulus controller, which reads the publishable key, Connect account, and messages from data attributes on the root element.

### Lemon Squeezy


Lemon Squeezy subscriptions now store the same statuses as every other processor: `trialing` instead of `on_trial` and `canceled` instead of `cancelled`. The end of a pause is stored in `pause_resumes_at` instead of `pause_starts_at`. Rows synced before this version keep the old values until they are synced again, so run this once after upgrading:

Expand Down
4 changes: 2 additions & 2 deletions app/models/pay/braintree/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ def cancel_now!(**options)
end

def change_quantity(quantity, **options)
raise NotImplementedError, "Braintree does not support setting quantity on subscriptions"
raise Pay::NotSupportedError, "Braintree does not support setting quantity on subscriptions"
end

def paused?
false
end

def pause
raise NotImplementedError, "Braintree does not support pausing subscriptions"
raise Pay::NotSupportedError, "Braintree does not support pausing subscriptions"
end

def resumable?
Expand Down
1 change: 1 addition & 0 deletions app/models/pay/charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Charge < Pay::ApplicationRecord
belongs_to :subscription, optional: true

# Scopes
scope :sorted, -> { order(created_at: :desc) }
scope :with_active_customer, -> { joins(:customer).merge(Customer.active) }
scope :with_deleted_customer, -> { joins(:customer).merge(Customer.deleted) }

Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/lemon_squeezy/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def update_api_record(**attributes)
end

def charge(amount, options = {})
raise NotImplementedError, "Lemon Squeezy does not support one-off charges"
raise Pay::NotSupportedError, "Lemon Squeezy does not support one-off charges"
end

def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options)
Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/lemon_squeezy/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def cancel(**options)
end

def cancel_now!(**options)
raise NotImplementedError, "Lemon Squeezy does not support cancelling immediately through the API"
raise Pay::NotSupportedError, "Lemon Squeezy does not support cancelling immediately through the API"
end

def change_quantity(quantity, **options)
Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/paddle_billing/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def charge(amount, options = {})
end

def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options)
raise NotImplementedError, "Paddle Billing subscriptions are created with Paddle Checkout and synced from webhooks"
raise Pay::NotSupportedError, "Paddle Billing subscriptions are created with Paddle Checkout and synced from webhooks"
end

# Paddle does not use payment method tokens. The method signature has it here
Expand Down
5 changes: 5 additions & 0 deletions app/models/pay/paddle_billing/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ class Subscription < Pay::Subscription
store_accessor :data, :paddle_update_url
store_accessor :data, :paddle_cancel_url

def self.sync_from_transaction(transaction_id)
Pay.deprecator.warn "Pay::PaddleBilling::Subscription.sync_from_transaction is deprecated, use Pay::PaddleBilling.sync_transaction instead"
Pay::PaddleBilling.sync_transaction(transaction_id)
end

def self.sync(subscription_id, object: nil, name: Pay.default_product_name)
sync_with_retries do
subscription = object || ::Paddle::Subscription.retrieve(id: subscription_id)
Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/paddle_classic/customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def charge(amount, options = {})
end

def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options)
raise NotImplementedError, "Paddle Classic subscriptions are created with Paddle Checkout and synced from webhooks"
raise Pay::NotSupportedError, "Paddle Classic subscriptions are created with Paddle Checkout and synced from webhooks"
end

# Paddle does not use payment method tokens. The method signature has it here
Expand Down
2 changes: 1 addition & 1 deletion app/models/pay/paddle_classic/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def cancel_now!(**options)
end

def change_quantity(quantity, **options)
raise NotImplementedError, "Paddle does not support setting quantity on subscriptions"
raise Pay::NotSupportedError, "Paddle does not support setting quantity on subscriptions"
end

# A subscription could be set to cancel or pause in the future
Expand Down
5 changes: 5 additions & 0 deletions app/models/pay/stripe/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ class Subscription < Pay::Subscription

attr_writer :api_record

def self.sync_from_checkout_session(session_id, stripe_account: nil)
Pay.deprecator.warn "Pay::Stripe::Subscription.sync_from_checkout_session is deprecated, use Pay::Stripe.sync_checkout_session instead"
Pay::Stripe.sync_checkout_session(session_id, stripe_account: stripe_account)
end

def self.sync(subscription_id, object: nil, name: nil, stripe_account: nil, retries: 1)
sync_with_retries(retries: retries) do
subscription = object || ::Stripe::Subscription.retrieve({id: subscription_id}.merge(expand_options), {stripe_account: stripe_account}.compact)
Expand Down
1 change: 1 addition & 0 deletions app/models/pay/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Subscription < Pay::ApplicationRecord
scope :for_name, ->(name) { where(name: name) }
scope :on_trial, -> { where(status: ["trialing", "active"]).where("trial_ends_at > ?", Time.current) }
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: "active").pause_not_started.where("#{table_name}.ends_at IS NULL OR #{table_name}.ends_at > ?", Time.current).or(on_trial) }
scope :paused, -> { where(status: "paused").or(where("pause_starts_at <= ?", Time.current)) }
Expand Down
10 changes: 10 additions & 0 deletions docs/stripe/8_stripe_checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ For one-time payments, you'll need to add a webhook listener for the Checkout `s

For subscriptions, Pay will automatically create the `Pay::Subscription` record for you.

The webhook can arrive after the customer lands on your `success_url`. To have the `Pay::Subscription` or `Pay::Charge` ready when they get there, sync the Checkout Session in your success action. Pay adds a `stripe_checkout_session_id` param to your `success_url` for this:

```ruby
def success
Pay::Stripe.sync_checkout_session(params[:stripe_checkout_session_id]) if params[:stripe_checkout_session_id]
end
```

`sync_checkout_session` syncs the subscription for `subscription` mode and the charge for `payment` mode. It retries a few times because Stripe doesn't always attach the subscription to the session right away.

To create custom webhook listeners for specific events, you can create your custom webhook listener classes under a folder like `app/webhooks`, like this:
```ruby
# app/webhooks/fulfill_checkout.rb
Expand Down
11 changes: 6 additions & 5 deletions lib/pay.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,16 @@ def self.support_email=(value)
@@emails.subscription_trial_will_end = true
@@emails.subscription_trial_ended = true

mattr_writer :mailer
@@mailer = "Pay::UserMailer"

def self.mailer=(value)
@@mailer = value
@@mailer_ref = nil
# Resolved on every call rather than memoized, so code reloading in development returns the current class
def self.mailer
@@mailer.constantize
end

def self.mailer
@@mailer_ref ||= @@mailer&.constantize
def self.deprecator
@deprecator ||= ActiveSupport::Deprecation.new("13.0", "Pay")
end

mattr_accessor :parent_mailer
Expand Down
Loading
Loading