From fb3de9a0ad3fe1eff4dd30ec5a2f866ad803ad58 Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Sun, 20 Sep 2026 18:13:02 -0500 Subject: [PATCH 1/3] Prepare for 12.0: upgrade guide, grouped changelog, Pay.mailer fix - UPGRADE.md gains a Pay 12.0 section covering every breaking change in the unreleased set: the removed methods with replacements, NotImplementedError for unsupported operations, Pay::Stripe::Error everywhere, RecordNotUnique on duplicates, the webhook controller extension points, regenerating the SCA view, and the Lemon Squeezy data update (moved from a 11.9 heading). - The Unreleased changelog is grouped into Breaking changes, Fixes and Improvements, and the duplicated Pay::Sync entry left over from the #1270 to #1276 replacement is removed. - Pay.mailer no longer memoizes the constantized class. The memo lived in a non-reloadable module, so development reloads returned a stale mailer class. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 34 +++++++++++++++++++++------------ UPGRADE.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++- lib/pay.rb | 9 +++------ test/pay_test.rb | 8 ++++++++ 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63151a43..8bf43e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,36 @@ ### 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`, 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 +* 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) * 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 + +#### 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 diff --git a/UPGRADE.md b/UPGRADE.md index ed56b0f9..2d911609 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,7 +2,54 @@ 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.cancelled` scope | `Pay::Subscription.canceled` (`cancelled?` on an instance still works) | +| `Pay::Subscription::STATUSES` | Not needed; statuses are validated by the processors | +| `Pay::Charge.sorted` | `order(created_at: :desc)` | +| `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::Stripe::Subscription.sync_from_checkout_session` | `Pay::Stripe.sync_checkout_session(session_id)` | +| `Pay::PaddleBilling::Subscription.sync_from_transaction` | `Pay::PaddleBilling.sync_transaction(transaction_id)` | +| `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 | + +### Unsupported operations raise `NotImplementedError` + +Calling something a processor cannot do now raises `NotImplementedError` with the processor named, instead of a `Pay::Error` or a silent `nil`. This affects `charge` and `cancel_now!` on Lemon Squeezy customers and subscriptions, and `subscribe` on Paddle Billing and Paddle Classic customers. `NotImplementedError` is not a `StandardError`, so a bare `rescue` or `rescue Pay::Error` no longer catches these. That is deliberate: they are programming errors, not runtime failures. + +### 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: diff --git a/lib/pay.rb b/lib/pay.rb index e0feedd2..36893882 100644 --- a/lib/pay.rb +++ b/lib/pay.rb @@ -76,15 +76,12 @@ 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 - end - + # Resolved on every call rather than memoized, so code reloading in development returns the current class def self.mailer - @@mailer_ref ||= @@mailer&.constantize + @@mailer.constantize end mattr_accessor :parent_mailer diff --git a/test/pay_test.rb b/test/pay_test.rb index aa9bceeb..2dc9ac2d 100644 --- a/test/pay_test.rb +++ b/test/pay_test.rb @@ -21,6 +21,14 @@ class Pay::Test < ActiveSupport::TestCase assert_equal Pay::UserMailer, Pay.mailer end + test "mailer is resolved on every call so a reloaded class is picked up" do + original = Pay.mailer + reloaded = Class.new(original) + String.any_instance.stubs(:constantize).returns(reloaded) + + assert_equal reloaded, Pay.mailer + end + {stripe: Pay::Stripe, braintree: Pay::Braintree, paddle_billing: Pay::PaddleBilling, paddle_classic: Pay::PaddleClassic, lemon_squeezy: Pay::LemonSqueezy}.each do |name, processor| test "can enable and disable the #{name} processor" do original = Pay.enabled_processors From 392c3919325042d3d6152efaad6e58546f15c9a7 Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Mon, 21 Sep 2026 10:38:14 -0500 Subject: [PATCH 2/3] Keep Pay::Charge.sorted Jumpstart Pro's billing page calls pay_charges.sorted, so removing it breaks every app built on it. Restore the scope, drop it from the 12.0 removals, and add a test so it isn't flagged as dead code again. --- CHANGELOG.md | 2 +- UPGRADE.md | 1 - app/models/pay/charge.rb | 1 + test/models/pay/charge_test.rb | 8 ++++++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf43e2a..e9242d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ 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`, 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 +* 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::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 * 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) * 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 diff --git a/UPGRADE.md b/UPGRADE.md index 2d911609..a5cc6b78 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -14,7 +14,6 @@ These had no callers in Pay and were never documented. Each has a replacement or | `Pay::Subscription#skip_trial` | `subscription.trial_ends_at = nil` | | `Pay::Subscription.cancelled` scope | `Pay::Subscription.canceled` (`cancelled?` on an instance still works) | | `Pay::Subscription::STATUSES` | Not needed; statuses are validated by the processors | -| `Pay::Charge.sorted` | `order(created_at: :desc)` | | `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) | diff --git a/app/models/pay/charge.rb b/app/models/pay/charge.rb index f09200e8..a064c09f 100644 --- a/app/models/pay/charge.rb +++ b/app/models/pay/charge.rb @@ -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) } diff --git a/test/models/pay/charge_test.rb b/test/models/pay/charge_test.rb index 18e34cd9..23e6160e 100644 --- a/test/models/pay/charge_test.rb +++ b/test/models/pay/charge_test.rb @@ -27,6 +27,14 @@ class Pay::Charge::Test < ActiveSupport::TestCase assert_equal "PayPal", charge.charged_to end + test "sorted scope orders newest first" do + customer = users(:stripe).payment_processor + older = customer.charges.create!(amount: 1, processor_id: "older", created_at: 2.days.ago) + newer = customer.charges.create!(amount: 1, processor_id: "newer", created_at: 1.day.ago) + + assert_equal [newer, older], customer.charges.where(id: [older, newer]).sorted.to_a + end + test "with_active_customer scope" do charge = pay_charges(:stripe) customer = charge.customer From 732ab176fc002ba5d4d5f4e8594a0f00a649548f Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Mon, 21 Sep 2026 11:10:09 -0500 Subject: [PATCH 3/3] Raise Pay::NotSupportedError and deprecate instead of removing sync methods Unsupported operations now raise Pay::NotSupportedError, a subclass of Pay::Error, instead of NotImplementedError. Apps that wrap these calls in `rescue Pay::Error` (Jumpstart Pro's subscription cancel and pause controllers do) caught the old Pay::Error on Lemon Squeezy and would have started returning 500s. Braintree pause and change_quantity and Paddle Classic change_quantity move over too, so every processor uses one error. Pay::Stripe::Subscription.sync_from_checkout_session and Pay::PaddleBilling::Subscription.sync_from_transaction come back as deprecated wrappers around Pay::Stripe.sync_checkout_session and Pay::PaddleBilling.sync_transaction, to be removed in Pay 13. Adds Pay.deprecator, registered with the app on Rails 7.1+. Restores the Pay::Subscription.cancelled scope to match the cancelled? predicate, and documents sync_checkout_session for Checkout success pages. Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 8 +++++-- UPGRADE.md | 24 +++++++++++++++---- app/models/pay/braintree/subscription.rb | 4 ++-- app/models/pay/lemon_squeezy/customer.rb | 2 +- app/models/pay/lemon_squeezy/subscription.rb | 2 +- app/models/pay/paddle_billing/customer.rb | 2 +- app/models/pay/paddle_billing/subscription.rb | 5 ++++ app/models/pay/paddle_classic/customer.rb | 2 +- app/models/pay/paddle_classic/subscription.rb | 2 +- app/models/pay/stripe/subscription.rb | 5 ++++ app/models/pay/subscription.rb | 1 + docs/stripe/8_stripe_checkout.md | 10 ++++++++ lib/pay.rb | 4 ++++ lib/pay/engine.rb | 4 ++++ lib/pay/errors.rb | 4 ++++ test/models/pay/subscription_test.rb | 12 ++++++++++ test/pay/lemon_squeezy/customer_test.rb | 2 +- test/pay/lemon_squeezy/subscription_test.rb | 5 ++++ test/pay/paddle_billing/customer_test.rb | 4 ++++ test/pay/paddle_billing/subscription_test.rb | 8 +++++++ test/pay/paddle_classic/customer_test.rb | 4 ++++ test/pay/stripe/processor_test.rb | 8 +++++++ 22 files changed, 107 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9242d5c..a28a9d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ 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`, the `cancelled` scope (the `cancelled?` predicate stays), `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 -* 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) +* 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 @@ -15,6 +15,10 @@ See the [UPGRADE guide](./UPGRADE.md#pay-120) for each of these. * `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 * Fix `Pay::Customer#has_incomplete_payment?`, which combined the `active` and `incomplete` scopes and could never return true diff --git a/UPGRADE.md b/UPGRADE.md index a5cc6b78..a7ff7c67 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -12,7 +12,6 @@ These had no callers in Pay and were never documented. Each has a replacement or |---|---| | `Pay::Subscription#has_trial?` | `trial_ends_at?` | | `Pay::Subscription#skip_trial` | `subscription.trial_ends_at = nil` | -| `Pay::Subscription.cancelled` scope | `Pay::Subscription.canceled` (`cancelled?` on an instance still works) | | `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` | @@ -20,14 +19,29 @@ These had no callers in Pay and were never documented. Each has a replacement or | `Pay::Receipts#filename` | `receipt_filename` | | `Pay::Payment#payment_intent?`, `#setup_intent?` | `intent.is_a?(::Stripe::PaymentIntent)` / `::Stripe::SetupIntent` | | `Pay::Payment#cancelled?` | `canceled?` | -| `Pay::Stripe::Subscription.sync_from_checkout_session` | `Pay::Stripe.sync_checkout_session(session_id)` | -| `Pay::PaddleBilling::Subscription.sync_from_transaction` | `Pay::PaddleBilling.sync_transaction(transaction_id)` | | `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 | -### Unsupported operations raise `NotImplementedError` +### Deprecated methods -Calling something a processor cannot do now raises `NotImplementedError` with the processor named, instead of a `Pay::Error` or a silent `nil`. This affects `charge` and `cancel_now!` on Lemon Squeezy customers and subscriptions, and `subscribe` on Paddle Billing and Paddle Classic customers. `NotImplementedError` is not a `StandardError`, so a bare `rescue` or `rescue Pay::Error` no longer catches these. That is deliberate: they are programming errors, not runtime failures. +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` diff --git a/app/models/pay/braintree/subscription.rb b/app/models/pay/braintree/subscription.rb index 81d1e7f3..725ea8ab 100644 --- a/app/models/pay/braintree/subscription.rb +++ b/app/models/pay/braintree/subscription.rb @@ -70,7 +70,7 @@ 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? @@ -78,7 +78,7 @@ def paused? end def pause - raise NotImplementedError, "Braintree does not support pausing subscriptions" + raise Pay::NotSupportedError, "Braintree does not support pausing subscriptions" end def resumable? diff --git a/app/models/pay/lemon_squeezy/customer.rb b/app/models/pay/lemon_squeezy/customer.rb index 0b948e71..ec235e29 100644 --- a/app/models/pay/lemon_squeezy/customer.rb +++ b/app/models/pay/lemon_squeezy/customer.rb @@ -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) diff --git a/app/models/pay/lemon_squeezy/subscription.rb b/app/models/pay/lemon_squeezy/subscription.rb index 3537d9c9..305144a9 100644 --- a/app/models/pay/lemon_squeezy/subscription.rb +++ b/app/models/pay/lemon_squeezy/subscription.rb @@ -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) diff --git a/app/models/pay/paddle_billing/customer.rb b/app/models/pay/paddle_billing/customer.rb index af4fa179..7eb9bf0e 100644 --- a/app/models/pay/paddle_billing/customer.rb +++ b/app/models/pay/paddle_billing/customer.rb @@ -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 diff --git a/app/models/pay/paddle_billing/subscription.rb b/app/models/pay/paddle_billing/subscription.rb index d7a62847..f992fa5b 100644 --- a/app/models/pay/paddle_billing/subscription.rb +++ b/app/models/pay/paddle_billing/subscription.rb @@ -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) diff --git a/app/models/pay/paddle_classic/customer.rb b/app/models/pay/paddle_classic/customer.rb index 0911cf4b..a5906a84 100644 --- a/app/models/pay/paddle_classic/customer.rb +++ b/app/models/pay/paddle_classic/customer.rb @@ -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 diff --git a/app/models/pay/paddle_classic/subscription.rb b/app/models/pay/paddle_classic/subscription.rb index 2f79dd16..72fee241 100644 --- a/app/models/pay/paddle_classic/subscription.rb +++ b/app/models/pay/paddle_classic/subscription.rb @@ -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 diff --git a/app/models/pay/stripe/subscription.rb b/app/models/pay/stripe/subscription.rb index 127a2ff3..eefb661a 100644 --- a/app/models/pay/stripe/subscription.rb +++ b/app/models/pay/stripe/subscription.rb @@ -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) diff --git a/app/models/pay/subscription.rb b/app/models/pay/subscription.rb index 2504f77a..aa510c35 100644 --- a/app/models/pay/subscription.rb +++ b/app/models/pay/subscription.rb @@ -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)) } diff --git a/docs/stripe/8_stripe_checkout.md b/docs/stripe/8_stripe_checkout.md index eb6261f8..fb858684 100644 --- a/docs/stripe/8_stripe_checkout.md +++ b/docs/stripe/8_stripe_checkout.md @@ -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 diff --git a/lib/pay.rb b/lib/pay.rb index 36893882..6d502336 100644 --- a/lib/pay.rb +++ b/lib/pay.rb @@ -84,6 +84,10 @@ def self.mailer @@mailer.constantize end + def self.deprecator + @deprecator ||= ActiveSupport::Deprecation.new("13.0", "Pay") + end + mattr_accessor :parent_mailer @@parent_mailer = "Pay::ApplicationMailer" diff --git a/lib/pay/engine.rb b/lib/pay/engine.rb index 728f579b..855651d8 100644 --- a/lib/pay/engine.rb +++ b/lib/pay/engine.rb @@ -17,6 +17,10 @@ class Engine < ::Rails::Engine end end + initializer "pay.deprecator" do |app| + app.deprecators[:pay] = Pay.deprecator if app.respond_to?(:deprecators) + end + initializer "pay.receipts" do if defined?(::Receipts::VERSION) raise "[Pay] receipts gem must be version ~> 2" unless Pay::Engine.version_matches?(required: "~> 2", current: ::Receipts::VERSION) diff --git a/lib/pay/errors.rb b/lib/pay/errors.rb index c1a6b126..1a072872 100644 --- a/lib/pay/errors.rb +++ b/lib/pay/errors.rb @@ -3,6 +3,10 @@ module Pay class Error < StandardError end + # Raised when a payment processor cannot perform an operation, such as pausing a Braintree subscription + class NotSupportedError < Error + end + class PaymentError < Error attr_reader :payment diff --git a/test/models/pay/subscription_test.rb b/test/models/pay/subscription_test.rb index 30ee8696..225a1762 100644 --- a/test/models/pay/subscription_test.rb +++ b/test/models/pay/subscription_test.rb @@ -76,6 +76,18 @@ class Pay::Subscription::Test < ActiveSupport::TestCase refute_includes subscriptions, subscription3 end + test "cancelled scope" do + subscription1 = create_subscription(ends_at: 7.days.ago) + subscription2 = create_subscription(ends_at: 7.days.from_now) + subscription3 = create_subscription(ends_at: nil) + + subscriptions = Pay::Subscription.cancelled + + assert_includes subscriptions, subscription1 + assert_includes subscriptions, subscription2 + refute_includes subscriptions, subscription3 + end + test "on grace period scope" do subscription1 = create_subscription(ends_at: 7.days.from_now) subscription2 = create_subscription(ends_at: nil) diff --git a/test/pay/lemon_squeezy/customer_test.rb b/test/pay/lemon_squeezy/customer_test.rb index 188e36bc..b4dc01a2 100644 --- a/test/pay/lemon_squeezy/customer_test.rb +++ b/test/pay/lemon_squeezy/customer_test.rb @@ -6,6 +6,6 @@ class Pay::LemonSqueezy::CustomerTest < ActiveSupport::TestCase end test "lemon squeezy cannot create a charge" do - assert_raises(NotImplementedError) { @pay_customer.charge(1000) } + assert_raises(Pay::NotSupportedError) { @pay_customer.charge(1000) } end end diff --git a/test/pay/lemon_squeezy/subscription_test.rb b/test/pay/lemon_squeezy/subscription_test.rb index ea7d77f8..431c2403 100644 --- a/test/pay/lemon_squeezy/subscription_test.rb +++ b/test/pay/lemon_squeezy/subscription_test.rb @@ -5,6 +5,11 @@ class Pay::LemonSqueezy::Subscription::Test < ActiveSupport::TestCase @pay_customer = pay_customers(:lemon_squeezy) end + test "lemon squeezy cancel_now! is not supported" do + error = assert_raises(Pay::NotSupportedError) { @pay_customer.subscription.cancel_now! } + assert_kind_of Pay::Error, error + end + test "lemon squeezy api_record" do assert_equal @pay_customer.subscription.api_record.class, ::LemonSqueezy::Subscription assert_equal "active", @pay_customer.subscription.status diff --git a/test/pay/paddle_billing/customer_test.rb b/test/pay/paddle_billing/customer_test.rb index 6a9df788..a65fde16 100644 --- a/test/pay/paddle_billing/customer_test.rb +++ b/test/pay/paddle_billing/customer_test.rb @@ -8,4 +8,8 @@ class Pay::PaddleBilling::CustomerTest < ActiveSupport::TestCase test "paddle cannot create a charge without options" do assert_raises(Paddle::Errors::ForbiddenError) { @pay_customer.charge(1000) } end + + test "paddle billing subscribe is not supported" do + assert_raises(Pay::NotSupportedError) { @pay_customer.subscribe } + end end diff --git a/test/pay/paddle_billing/subscription_test.rb b/test/pay/paddle_billing/subscription_test.rb index c397226e..42239cc7 100644 --- a/test/pay/paddle_billing/subscription_test.rb +++ b/test/pay/paddle_billing/subscription_test.rb @@ -5,6 +5,14 @@ class Pay::PaddleBilling::Subscription::Test < ActiveSupport::TestCase @pay_customer = pay_customers(:paddle_billing) end + test "sync_from_transaction is deprecated in favor of Pay::PaddleBilling.sync_transaction" do + Pay::PaddleBilling.expects(:sync_transaction).with("txn_1").returns(:synced) + + assert_deprecated(/Pay::PaddleBilling.sync_transaction/, Pay.deprecator) do + assert_equal :synced, Pay::PaddleBilling::Subscription.sync_from_transaction("txn_1") + end + end + test "paddle billing processor subscription" do assert_equal @pay_customer.subscription.api_record.class, ::Paddle::Subscription assert_equal "active", @pay_customer.subscription.status diff --git a/test/pay/paddle_classic/customer_test.rb b/test/pay/paddle_classic/customer_test.rb index b8d3bcdf..bc9a7843 100644 --- a/test/pay/paddle_classic/customer_test.rb +++ b/test/pay/paddle_classic/customer_test.rb @@ -15,6 +15,10 @@ class Pay::PaddleClassic::CustomerTest < ActiveSupport::TestCase assert_raises(Pay::Error) { @pay_customer.charge(1000) } end + test "paddle classic subscribe is not supported" do + assert_raises(Pay::NotSupportedError) { @pay_customer.subscribe } + end + test "paddle classic can sync payment information" do Pay::PaddleClassic::PaymentMethod.sync(pay_customer: @pay_customer) diff --git a/test/pay/stripe/processor_test.rb b/test/pay/stripe/processor_test.rb index 6d805f82..fdaa99b2 100644 --- a/test/pay/stripe/processor_test.rb +++ b/test/pay/stripe/processor_test.rb @@ -126,4 +126,12 @@ class Pay::Stripe::ProcessorTest < ActiveSupport::TestCase error = assert_raises(Pay::Stripe::Error) { Pay::Stripe.sync_checkout_session("cs_1", retries: 2) } assert_kind_of ::Stripe::InvalidRequestError, error.cause end + + test "Subscription.sync_from_checkout_session is deprecated in favor of sync_checkout_session" do + Pay::Stripe.expects(:sync_checkout_session).with("cs_1", stripe_account: "acct_1").returns(:synced) + + assert_deprecated(/Pay::Stripe.sync_checkout_session/, Pay.deprecator) do + assert_equal :synced, Pay::Stripe::Subscription.sync_from_checkout_session("cs_1", stripe_account: "acct_1") + end + end end