paddle_rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +294 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/paddle_rails/application.css +16 -0
- data/app/assets/stylesheets/paddle_rails/tailwind.css +1824 -0
- data/app/assets/tailwind/application.css +1 -0
- data/app/controllers/concerns/paddle_rails/paddle_checkout_error_handler.rb +89 -0
- data/app/controllers/concerns/paddle_rails/subscription_owner.rb +16 -0
- data/app/controllers/paddle_rails/application_controller.rb +21 -0
- data/app/controllers/paddle_rails/checkout_controller.rb +121 -0
- data/app/controllers/paddle_rails/dashboard_controller.rb +37 -0
- data/app/controllers/paddle_rails/onboarding_controller.rb +55 -0
- data/app/controllers/paddle_rails/payments_controller.rb +62 -0
- data/app/controllers/paddle_rails/subscriptions_controller.rb +92 -0
- data/app/controllers/paddle_rails/webhooks_controller.rb +78 -0
- data/app/helpers/paddle_rails/application_helper.rb +121 -0
- data/app/helpers/paddle_rails/subscription_owner_helper.rb +14 -0
- data/app/jobs/paddle_rails/application_job.rb +4 -0
- data/app/jobs/paddle_rails/process_webhook_job.rb +38 -0
- data/app/mailers/paddle_rails/application_mailer.rb +6 -0
- data/app/models/concerns/paddle_rails/subscribable.rb +46 -0
- data/app/models/paddle_rails/application_record.rb +5 -0
- data/app/models/paddle_rails/payment.rb +43 -0
- data/app/models/paddle_rails/price.rb +25 -0
- data/app/models/paddle_rails/product.rb +16 -0
- data/app/models/paddle_rails/subscription.rb +87 -0
- data/app/models/paddle_rails/subscription_item.rb +35 -0
- data/app/models/paddle_rails/webhook_event.rb +51 -0
- data/app/presenters/paddle_rails/payment_presenter.rb +96 -0
- data/app/presenters/paddle_rails/product_presenter.rb +178 -0
- data/app/presenters/paddle_rails/subscription_presenter.rb +145 -0
- data/app/views/layouts/paddle_rails/application.html.erb +170 -0
- data/app/views/paddle_rails/checkout/show.html.erb +128 -0
- data/app/views/paddle_rails/dashboard/_change_plan.html.erb +286 -0
- data/app/views/paddle_rails/dashboard/_current_subscription.html.erb +66 -0
- data/app/views/paddle_rails/dashboard/_payment_history.html.erb +79 -0
- data/app/views/paddle_rails/dashboard/_payment_method.html.erb +48 -0
- data/app/views/paddle_rails/dashboard/show.html.erb +47 -0
- data/app/views/paddle_rails/onboarding/show.html.erb +100 -0
- data/app/views/paddle_rails/shared/configuration_error.html.erb +94 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20251124180624_create_paddle_rails_subscription_plans.rb +18 -0
- data/db/migrate/20251124180817_create_paddle_rails_subscription_prices.rb +26 -0
- data/db/migrate/20251127221947_create_paddle_rails_webhook_events.rb +19 -0
- data/db/migrate/20251128135831_create_paddle_rails_subscriptions.rb +21 -0
- data/db/migrate/20251128142327_create_paddle_rails_subscription_items.rb +16 -0
- data/db/migrate/20251128151334_remove_paddle_price_id_from_subscriptions.rb +7 -0
- data/db/migrate/20251128151401_rename_subscription_plans_to_products.rb +6 -0
- data/db/migrate/20251128151402_rename_subscription_plan_id_to_subscription_product_id.rb +13 -0
- data/db/migrate/20251128151453_remove_subscription_price_id_from_subscriptions.rb +8 -0
- data/db/migrate/20251128151501_add_subscription_product_id_to_subscription_items.rb +8 -0
- data/db/migrate/20251128152025_remove_paddle_item_id_from_subscription_items.rb +6 -0
- data/db/migrate/20251128212046_rename_subscription_products_to_products.rb +6 -0
- data/db/migrate/20251128212047_rename_subscription_prices_to_prices.rb +6 -0
- data/db/migrate/20251128212053_rename_subscription_product_id_to_product_id_in_prices.rb +13 -0
- data/db/migrate/20251128212054_rename_fks_in_subscription_items.rb +20 -0
- data/db/migrate/20251128220016_add_scheduled_cancelation_at_to_subscriptions.rb +6 -0
- data/db/migrate/20251129121336_add_payment_method_to_subscriptions.rb +10 -0
- data/db/migrate/20251129222345_create_paddle_rails_payments.rb +24 -0
- data/lib/paddle_rails/checkout.rb +181 -0
- data/lib/paddle_rails/configuration.rb +121 -0
- data/lib/paddle_rails/engine.rb +49 -0
- data/lib/paddle_rails/product_sync.rb +176 -0
- data/lib/paddle_rails/subscription_sync.rb +303 -0
- data/lib/paddle_rails/version.rb +6 -0
- data/lib/paddle_rails/webhook_processor.rb +102 -0
- data/lib/paddle_rails/webhook_verifier.rb +110 -0
- data/lib/paddle_rails.rb +32 -0
- data/lib/tasks/paddle_rails_tasks.rake +15 -0
- metadata +157 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
module PaddleCheckoutErrorHandler
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
rescue_from Paddle::Errors::BadRequestError, with: :handle_paddle_bad_request_error
|
|
9
|
+
rescue_from StandardError, with: :handle_paddle_checkout_error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Handles Paddle::Errors::BadRequestError with special handling for domain approval errors in development.
|
|
15
|
+
#
|
|
16
|
+
# @param error [Paddle::Errors::BadRequestError] the error to handle
|
|
17
|
+
# @return [void]
|
|
18
|
+
def handle_paddle_bad_request_error(error)
|
|
19
|
+
# Only handle errors for checkout actions
|
|
20
|
+
unless action_name == "create_checkout"
|
|
21
|
+
raise error
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if error.message.include?("transaction_checkout_url_domain_is_not_approved") &&
|
|
25
|
+
defined?(Rails) && Rails.env.development?
|
|
26
|
+
|
|
27
|
+
domain = extract_domain_from_url(paddle_checkout_fallback_url)
|
|
28
|
+
error_message = build_domain_approval_error_message(domain)
|
|
29
|
+
redirect_to paddle_checkout_fallback_path, alert: error_message.html_safe
|
|
30
|
+
else
|
|
31
|
+
Rails.logger.error("PaddleRails::PaddleCheckoutErrorHandler: Error creating checkout: #{error.message}")
|
|
32
|
+
redirect_to paddle_checkout_fallback_path, alert: "Failed to create checkout. Please try again."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Handles general Paddle checkout errors.
|
|
37
|
+
#
|
|
38
|
+
# @param error [Exception] the error to handle
|
|
39
|
+
# @return [void]
|
|
40
|
+
def handle_paddle_checkout_error(error)
|
|
41
|
+
# Only handle errors for checkout actions
|
|
42
|
+
unless action_name == "create_checkout"
|
|
43
|
+
raise error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if this is actually a BadRequestError that wasn't caught (shouldn't happen, but just in case)
|
|
47
|
+
if error.is_a?(Paddle::Errors::BadRequestError)
|
|
48
|
+
handle_paddle_bad_request_error(error)
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Rails.logger.error("PaddleRails::PaddleCheckoutErrorHandler: StandardError - #{error.class}: #{error.message}")
|
|
53
|
+
redirect_to paddle_checkout_fallback_path, alert: "Failed to create checkout. Please try again."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the fallback path for checkout errors. Override in controller if needed.
|
|
57
|
+
#
|
|
58
|
+
# @return [String, Symbol]
|
|
59
|
+
def paddle_checkout_fallback_path
|
|
60
|
+
onboarding_path
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the current URL for domain extraction. Override in controller if needed.
|
|
64
|
+
#
|
|
65
|
+
# @return [String, nil]
|
|
66
|
+
def paddle_checkout_fallback_url
|
|
67
|
+
onboarding_url
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_domain_from_url(url)
|
|
71
|
+
return "localhost" unless url
|
|
72
|
+
|
|
73
|
+
URI.parse(url).host
|
|
74
|
+
rescue StandardError
|
|
75
|
+
"localhost"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_domain_approval_error_message(domain)
|
|
79
|
+
<<~MSG.squish
|
|
80
|
+
Your checkout_url domain (#{domain}) is not approved by Paddle.
|
|
81
|
+
For development and local testing, you can either:
|
|
82
|
+
(1) run your app behind a reverse proxy like Ngrok or Cloudflare Tunnel and add the assigned HTTPS domain,
|
|
83
|
+
or (2) use a local hostname via /etc/hosts (e.g., application.local) and add it to your Paddle "Approved Domains" settings.
|
|
84
|
+
<a href="https://sandbox-vendors.paddle.com/request-domain-approval" target="_blank" rel="noopener noreferrer" class="underline font-medium hover:text-red-900">Request domain approval</a>.
|
|
85
|
+
MSG
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module PaddleRails
|
|
2
|
+
module SubscriptionOwner
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
def subscription_owner
|
|
6
|
+
authenticator = PaddleRails.configuration.subscription_owner_authenticator
|
|
7
|
+
return nil unless authenticator
|
|
8
|
+
|
|
9
|
+
instance_eval(&authenticator)
|
|
10
|
+
rescue StandardError => e
|
|
11
|
+
Rails.logger.error("PaddleRails::SubscriptionOwner: Error authenticating subscription owner: #{e.message}")
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module PaddleRails
|
|
2
|
+
class ApplicationController < ::ApplicationController
|
|
3
|
+
include SubscriptionOwner
|
|
4
|
+
|
|
5
|
+
before_action :ensure_subscription_owner
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def ensure_subscription_owner
|
|
10
|
+
unless subscription_owner
|
|
11
|
+
redirect_to main_app.root_path, alert: "You must be signed in to access this page."
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
unless subscription_owner.respond_to?(:subscription?)
|
|
16
|
+
render template: "paddle_rails/shared/configuration_error", status: :internal_server_error, layout: "paddle_rails/application"
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class CheckoutController < ApplicationController
|
|
5
|
+
before_action :require_transaction_id, only: [:show]
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
# Paddle.js will automatically open the checkout when the transaction ID
|
|
9
|
+
# is passed as a query parameter. No additional logic needed.
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Creates a transaction to update payment method and redirects to checkout.
|
|
13
|
+
#
|
|
14
|
+
# Uses the Paddle API endpoint:
|
|
15
|
+
# GET /subscriptions/{subscription_id}/update-payment-method-transaction
|
|
16
|
+
#
|
|
17
|
+
# @see https://developer.paddle.com/api-reference/subscriptions/update-payment-method
|
|
18
|
+
def update_payment_method
|
|
19
|
+
subscription = subscription_owner.subscription
|
|
20
|
+
|
|
21
|
+
unless subscription
|
|
22
|
+
redirect_to root_path, alert: "No active subscription found."
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
# Call Paddle API to get a transaction for updating payment method
|
|
28
|
+
# This returns a transaction with a checkout URL
|
|
29
|
+
# https://developer.paddle.com/api-reference/subscriptions/update-payment-method
|
|
30
|
+
response = Paddle::Subscription.get_transaction(
|
|
31
|
+
id: subscription.paddle_subscription_id
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
checkout_url = response.checkout&.url
|
|
35
|
+
|
|
36
|
+
unless checkout_url.present?
|
|
37
|
+
redirect_to root_path, alert: "Unable to create payment update session. Please try again."
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Redirect to Paddle checkout to update payment method
|
|
42
|
+
redirect_to checkout_url, allow_other_host: true
|
|
43
|
+
rescue Paddle::Error => e
|
|
44
|
+
Rails.logger.error("PaddleRails::CheckoutController: Failed to get payment update transaction: #{e.message}")
|
|
45
|
+
redirect_to root_path, alert: "Failed to update payment method. Please try again."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_status
|
|
50
|
+
transaction_id = params[:transaction_id]
|
|
51
|
+
|
|
52
|
+
unless transaction_id.present?
|
|
53
|
+
render json: { status: "error", message: "Transaction ID is required" }, status: :bad_request
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Try to find subscription locally first by checking webhook events
|
|
58
|
+
# or by querying Paddle transaction
|
|
59
|
+
subscription = find_subscription_by_transaction(transaction_id)
|
|
60
|
+
|
|
61
|
+
# If not found locally, proactively sync from Paddle
|
|
62
|
+
unless subscription
|
|
63
|
+
begin
|
|
64
|
+
transaction = Paddle::Transaction.retrieve(id: transaction_id)
|
|
65
|
+
|
|
66
|
+
# Extract subscription_id from transaction
|
|
67
|
+
subscription_id = transaction.subscription_id
|
|
68
|
+
|
|
69
|
+
if subscription_id.present?
|
|
70
|
+
# Sync the subscription from Paddle
|
|
71
|
+
subscription = SubscriptionSync.sync_from_paddle(subscription_id)
|
|
72
|
+
end
|
|
73
|
+
rescue => e
|
|
74
|
+
Rails.logger.error("PaddleRails::CheckoutController: Error fetching transaction #{transaction_id}: #{e.message}")
|
|
75
|
+
render json: { status: "pending", message: "Transaction not yet processed" }, status: :ok
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if subscription is active
|
|
81
|
+
if subscription&.active?
|
|
82
|
+
render json: {
|
|
83
|
+
status: "active",
|
|
84
|
+
redirect_url: paddle_rails.root_path
|
|
85
|
+
}, status: :ok
|
|
86
|
+
else
|
|
87
|
+
render json: { status: "pending", message: "Subscription is being processed" }, status: :ok
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def require_transaction_id
|
|
94
|
+
unless params[:_ptxn].present?
|
|
95
|
+
redirect_to onboarding_path, alert: "Invalid checkout session. Please try again."
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Try to find a subscription by transaction ID.
|
|
100
|
+
# This checks webhook events as a fallback since we don't store transaction_id on subscriptions.
|
|
101
|
+
#
|
|
102
|
+
# @param transaction_id [String] The Paddle transaction ID
|
|
103
|
+
# @return [PaddleRails::Subscription, nil]
|
|
104
|
+
def find_subscription_by_transaction(transaction_id)
|
|
105
|
+
# Check webhook events for this transaction
|
|
106
|
+
webhook_event = WebhookEvent.where("payload->>'transaction_id' = ?", transaction_id).first
|
|
107
|
+
|
|
108
|
+
if webhook_event
|
|
109
|
+
payload = webhook_event.payload
|
|
110
|
+
subscription_id = payload.dig("subscription_id") || payload.dig("data", "subscription_id")
|
|
111
|
+
|
|
112
|
+
if subscription_id
|
|
113
|
+
return Subscription.find_by(paddle_subscription_id: subscription_id)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module PaddleRails
|
|
2
|
+
class DashboardController < ApplicationController
|
|
3
|
+
before_action :redirect_to_onboarding_if_no_subscription
|
|
4
|
+
|
|
5
|
+
def show
|
|
6
|
+
@subscription = SubscriptionPresenter.new(subscription_owner.subscription)
|
|
7
|
+
|
|
8
|
+
# Load products for change plan widget
|
|
9
|
+
products = Product.active.includes(:prices)
|
|
10
|
+
sorted_products = products.sort_by do |product|
|
|
11
|
+
product.prices.active.minimum(:unit_price) || Float::INFINITY
|
|
12
|
+
end
|
|
13
|
+
@products = sorted_products.each_with_index.map do |product, index|
|
|
14
|
+
ProductPresenter.new(product, index: index)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Load payments for payment history widget
|
|
18
|
+
@payments = if subscription_owner.subscription
|
|
19
|
+
subscription_owner.subscription.payments.completed.recent.limit(10).map do |payment|
|
|
20
|
+
PaymentPresenter.new(payment)
|
|
21
|
+
end
|
|
22
|
+
else
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def redirect_to_onboarding_if_no_subscription
|
|
30
|
+
return unless subscription_owner
|
|
31
|
+
|
|
32
|
+
unless subscription_owner.subscription?
|
|
33
|
+
redirect_to onboarding_path
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module PaddleRails
|
|
2
|
+
class OnboardingController < ApplicationController
|
|
3
|
+
# include PaddleCheckoutErrorHandler
|
|
4
|
+
|
|
5
|
+
before_action :redirect_to_dashboard_if_subscribed
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
products = Product.active.includes(:prices)
|
|
9
|
+
|
|
10
|
+
# Order products by their lowest active price (cheapest first)
|
|
11
|
+
sorted_products = products.sort_by do |product|
|
|
12
|
+
product.prices.active.minimum(:unit_price) || Float::INFINITY
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@products = sorted_products.each_with_index.map do |product, index|
|
|
16
|
+
ProductPresenter.new(product, index: index)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create_checkout
|
|
21
|
+
price = Price.find_by(paddle_price_id: params[:paddle_price_id])
|
|
22
|
+
|
|
23
|
+
unless price&.active?
|
|
24
|
+
redirect_to onboarding_path, alert: "Invalid price selected."
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
redirect_url = PaddleRails::Checkout.url_for(
|
|
29
|
+
owner: subscription_owner,
|
|
30
|
+
paddle_price_id: price.paddle_price_id,
|
|
31
|
+
checkout_url: checkout_url
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if redirect_url.present?
|
|
35
|
+
redirect_to redirect_url, allow_other_host: true
|
|
36
|
+
else
|
|
37
|
+
redirect_to onboarding_path, alert: "Failed to create checkout. Please try again."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def redirect_to_dashboard_if_subscribed
|
|
44
|
+
return unless subscription_owner
|
|
45
|
+
|
|
46
|
+
# Redirect to dashboard if user already has a subscription
|
|
47
|
+
if subscription_owner.respond_to?(:subscription) && subscription_owner.subscription.present?
|
|
48
|
+
redirect_to root_path
|
|
49
|
+
elsif subscription_owner.respond_to?(:subscription?) && subscription_owner.subscription?
|
|
50
|
+
redirect_to root_path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class PaymentsController < ApplicationController
|
|
5
|
+
# View invoice in browser (disposition: inline)
|
|
6
|
+
# GET /payments/:id/invoice
|
|
7
|
+
def view_invoice
|
|
8
|
+
payment = find_payment
|
|
9
|
+
return unless payment
|
|
10
|
+
|
|
11
|
+
redirect_to_invoice(payment, disposition: "inline")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Download invoice as PDF (disposition: attachment)
|
|
15
|
+
# GET /payments/:id/download
|
|
16
|
+
def download_invoice
|
|
17
|
+
payment = find_payment
|
|
18
|
+
return unless payment
|
|
19
|
+
|
|
20
|
+
redirect_to_invoice(payment, disposition: "attachment")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def find_payment
|
|
26
|
+
payment = Payment.find_by(id: params[:id])
|
|
27
|
+
|
|
28
|
+
unless payment
|
|
29
|
+
redirect_to root_path, alert: "Payment not found."
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Verify the payment belongs to the current subscription owner
|
|
34
|
+
unless payment.owner == subscription_owner
|
|
35
|
+
redirect_to root_path, alert: "Access denied."
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
payment
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def redirect_to_invoice(payment, disposition:)
|
|
43
|
+
unless payment.paddle_transaction_id.present?
|
|
44
|
+
redirect_to root_path, alert: "No invoice available for this payment."
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
invoice_url = Paddle::Transaction.invoice(
|
|
50
|
+
id: payment.paddle_transaction_id,
|
|
51
|
+
disposition: disposition
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
redirect_to invoice_url, allow_other_host: true
|
|
55
|
+
rescue Paddle::Error => e
|
|
56
|
+
Rails.logger.error("PaddleRails::PaymentsController: Failed to get invoice: #{e.message}")
|
|
57
|
+
redirect_to root_path, alert: "Failed to retrieve invoice. Please try again."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class SubscriptionsController < ApplicationController
|
|
5
|
+
def revoke_cancellation
|
|
6
|
+
subscription = subscription_owner.subscription
|
|
7
|
+
|
|
8
|
+
unless subscription
|
|
9
|
+
redirect_to root_path, alert: "No active subscription found."
|
|
10
|
+
return
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
unless subscription.scheduled_for_cancellation?
|
|
14
|
+
redirect_to root_path, notice: "Subscription is not scheduled for cancellation."
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
# Use Paddle SDK to remove the scheduled change
|
|
20
|
+
Paddle::Subscription.update(
|
|
21
|
+
id: subscription.paddle_subscription_id,
|
|
22
|
+
scheduled_change: nil
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Update local record immediately
|
|
26
|
+
subscription.update!(scheduled_cancelation_at: nil)
|
|
27
|
+
|
|
28
|
+
redirect_to root_path, notice: "Cancellation has been revoked. Your subscription will continue."
|
|
29
|
+
rescue Paddle::Error => e
|
|
30
|
+
Rails.logger.error("PaddleRails::SubscriptionsController: Failed to revoke cancellation: #{e.message}")
|
|
31
|
+
redirect_to root_path, alert: "Failed to revoke cancellation. Please try again."
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cancel
|
|
36
|
+
subscription = subscription_owner.subscription
|
|
37
|
+
|
|
38
|
+
unless subscription&.active?
|
|
39
|
+
redirect_to root_path, alert: "No active subscription found."
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
# Schedule cancellation at end of period
|
|
45
|
+
Paddle::Subscription.cancel(
|
|
46
|
+
id: subscription.paddle_subscription_id,
|
|
47
|
+
effective_from: "next_billing_period"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Update local record immediately
|
|
51
|
+
# We assume it's scheduled for next_billing_period, so we use current_period_end_at
|
|
52
|
+
subscription.update!(scheduled_cancelation_at: subscription.current_period_end_at)
|
|
53
|
+
|
|
54
|
+
redirect_to root_path, notice: "Subscription scheduled for cancellation on #{subscription.current_period_end_at&.strftime('%B %d, %Y')}."
|
|
55
|
+
rescue Paddle::Error => e
|
|
56
|
+
Rails.logger.error("PaddleRails::SubscriptionsController: Failed to cancel subscription: #{e.message}")
|
|
57
|
+
redirect_to root_path, alert: "Failed to cancel subscription. Please try again."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def change_plan
|
|
62
|
+
subscription = subscription_owner.subscription
|
|
63
|
+
price = Price.find_by(paddle_price_id: params[:paddle_price_id])
|
|
64
|
+
|
|
65
|
+
unless subscription
|
|
66
|
+
redirect_to root_path, alert: "No active subscription found."
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless price&.active?
|
|
71
|
+
redirect_to root_path, alert: "Invalid price selected."
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
Paddle::Subscription.update(
|
|
77
|
+
id: subscription.paddle_subscription_id,
|
|
78
|
+
items: [{ price_id: price.paddle_price_id, quantity: 1 }],
|
|
79
|
+
proration_billing_mode: "prorated_immediately"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Sync subscription from Paddle immediately to reflect the change
|
|
83
|
+
SubscriptionSync.sync_from_paddle(subscription.paddle_subscription_id)
|
|
84
|
+
|
|
85
|
+
redirect_to root_path, notice: "Plan changed successfully."
|
|
86
|
+
rescue Paddle::Error => e
|
|
87
|
+
Rails.logger.error("PaddleRails::SubscriptionsController: Failed to change plan: #{e.message}")
|
|
88
|
+
redirect_to root_path, alert: "Failed to change plan. Please try again."
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
# Controller for receiving webhook events from Paddle.
|
|
5
|
+
#
|
|
6
|
+
# This controller:
|
|
7
|
+
# - Verifies webhook signatures
|
|
8
|
+
# - Stores raw events in the database
|
|
9
|
+
# - Enqueues background jobs for processing
|
|
10
|
+
#
|
|
11
|
+
# Webhooks are processed asynchronously to ensure fast response times
|
|
12
|
+
# and allow for retry logic.
|
|
13
|
+
class WebhooksController < ActionController::API
|
|
14
|
+
# Receive and process a webhook event from Paddle.
|
|
15
|
+
#
|
|
16
|
+
# POST /paddle_rails/webhooks
|
|
17
|
+
def create
|
|
18
|
+
# Get raw body (must not be transformed for signature verification)
|
|
19
|
+
raw_body = request.raw_post
|
|
20
|
+
signature_header = request.headers["Paddle-Signature"]
|
|
21
|
+
|
|
22
|
+
# Verify signature
|
|
23
|
+
unless verify_signature(raw_body, signature_header)
|
|
24
|
+
Rails.logger.error("PaddleRails::WebhooksController: Invalid webhook signature")
|
|
25
|
+
head :unauthorized
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Parse payload
|
|
30
|
+
payload = JSON.parse(raw_body)
|
|
31
|
+
external_id = payload["event_id"]
|
|
32
|
+
event_type = payload["event_type"]
|
|
33
|
+
|
|
34
|
+
# Check if we've already processed this event (idempotency)
|
|
35
|
+
existing_event = WebhookEvent.find_by(external_id: external_id)
|
|
36
|
+
if existing_event
|
|
37
|
+
Rails.logger.info("PaddleRails::WebhooksController: Webhook #{external_id} already processed, skipping")
|
|
38
|
+
head :ok
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create webhook event record
|
|
43
|
+
webhook_event = WebhookEvent.create!(
|
|
44
|
+
external_id: external_id,
|
|
45
|
+
event_type: event_type,
|
|
46
|
+
payload: payload,
|
|
47
|
+
status: WebhookEvent::PENDING
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Enqueue background job for processing
|
|
51
|
+
ProcessWebhookJob.perform_later(webhook_event.id)
|
|
52
|
+
|
|
53
|
+
head :ok
|
|
54
|
+
rescue JSON::ParserError => e
|
|
55
|
+
Rails.logger.error("PaddleRails::WebhooksController: Invalid JSON payload: #{e.message}")
|
|
56
|
+
head :bad_request
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
Rails.logger.error("PaddleRails::WebhooksController: Error processing webhook: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
59
|
+
head :internal_server_error
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Verify the webhook signature.
|
|
65
|
+
#
|
|
66
|
+
# @param raw_body [String] The raw request body
|
|
67
|
+
# @param signature_header [String] The Paddle-Signature header value
|
|
68
|
+
# @return [Boolean] true if signature is valid
|
|
69
|
+
def verify_signature(raw_body, signature_header)
|
|
70
|
+
secret_key = PaddleRails.configuration.webhook_secret
|
|
71
|
+
return false if secret_key.blank?
|
|
72
|
+
|
|
73
|
+
verifier = WebhookVerifier.new(secret_key)
|
|
74
|
+
verifier.verify(raw_body, signature_header)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|