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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +294 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/paddle_rails/application.css +16 -0
  6. data/app/assets/stylesheets/paddle_rails/tailwind.css +1824 -0
  7. data/app/assets/tailwind/application.css +1 -0
  8. data/app/controllers/concerns/paddle_rails/paddle_checkout_error_handler.rb +89 -0
  9. data/app/controllers/concerns/paddle_rails/subscription_owner.rb +16 -0
  10. data/app/controllers/paddle_rails/application_controller.rb +21 -0
  11. data/app/controllers/paddle_rails/checkout_controller.rb +121 -0
  12. data/app/controllers/paddle_rails/dashboard_controller.rb +37 -0
  13. data/app/controllers/paddle_rails/onboarding_controller.rb +55 -0
  14. data/app/controllers/paddle_rails/payments_controller.rb +62 -0
  15. data/app/controllers/paddle_rails/subscriptions_controller.rb +92 -0
  16. data/app/controllers/paddle_rails/webhooks_controller.rb +78 -0
  17. data/app/helpers/paddle_rails/application_helper.rb +121 -0
  18. data/app/helpers/paddle_rails/subscription_owner_helper.rb +14 -0
  19. data/app/jobs/paddle_rails/application_job.rb +4 -0
  20. data/app/jobs/paddle_rails/process_webhook_job.rb +38 -0
  21. data/app/mailers/paddle_rails/application_mailer.rb +6 -0
  22. data/app/models/concerns/paddle_rails/subscribable.rb +46 -0
  23. data/app/models/paddle_rails/application_record.rb +5 -0
  24. data/app/models/paddle_rails/payment.rb +43 -0
  25. data/app/models/paddle_rails/price.rb +25 -0
  26. data/app/models/paddle_rails/product.rb +16 -0
  27. data/app/models/paddle_rails/subscription.rb +87 -0
  28. data/app/models/paddle_rails/subscription_item.rb +35 -0
  29. data/app/models/paddle_rails/webhook_event.rb +51 -0
  30. data/app/presenters/paddle_rails/payment_presenter.rb +96 -0
  31. data/app/presenters/paddle_rails/product_presenter.rb +178 -0
  32. data/app/presenters/paddle_rails/subscription_presenter.rb +145 -0
  33. data/app/views/layouts/paddle_rails/application.html.erb +170 -0
  34. data/app/views/paddle_rails/checkout/show.html.erb +128 -0
  35. data/app/views/paddle_rails/dashboard/_change_plan.html.erb +286 -0
  36. data/app/views/paddle_rails/dashboard/_current_subscription.html.erb +66 -0
  37. data/app/views/paddle_rails/dashboard/_payment_history.html.erb +79 -0
  38. data/app/views/paddle_rails/dashboard/_payment_method.html.erb +48 -0
  39. data/app/views/paddle_rails/dashboard/show.html.erb +47 -0
  40. data/app/views/paddle_rails/onboarding/show.html.erb +100 -0
  41. data/app/views/paddle_rails/shared/configuration_error.html.erb +94 -0
  42. data/config/routes.rb +13 -0
  43. data/db/migrate/20251124180624_create_paddle_rails_subscription_plans.rb +18 -0
  44. data/db/migrate/20251124180817_create_paddle_rails_subscription_prices.rb +26 -0
  45. data/db/migrate/20251127221947_create_paddle_rails_webhook_events.rb +19 -0
  46. data/db/migrate/20251128135831_create_paddle_rails_subscriptions.rb +21 -0
  47. data/db/migrate/20251128142327_create_paddle_rails_subscription_items.rb +16 -0
  48. data/db/migrate/20251128151334_remove_paddle_price_id_from_subscriptions.rb +7 -0
  49. data/db/migrate/20251128151401_rename_subscription_plans_to_products.rb +6 -0
  50. data/db/migrate/20251128151402_rename_subscription_plan_id_to_subscription_product_id.rb +13 -0
  51. data/db/migrate/20251128151453_remove_subscription_price_id_from_subscriptions.rb +8 -0
  52. data/db/migrate/20251128151501_add_subscription_product_id_to_subscription_items.rb +8 -0
  53. data/db/migrate/20251128152025_remove_paddle_item_id_from_subscription_items.rb +6 -0
  54. data/db/migrate/20251128212046_rename_subscription_products_to_products.rb +6 -0
  55. data/db/migrate/20251128212047_rename_subscription_prices_to_prices.rb +6 -0
  56. data/db/migrate/20251128212053_rename_subscription_product_id_to_product_id_in_prices.rb +13 -0
  57. data/db/migrate/20251128212054_rename_fks_in_subscription_items.rb +20 -0
  58. data/db/migrate/20251128220016_add_scheduled_cancelation_at_to_subscriptions.rb +6 -0
  59. data/db/migrate/20251129121336_add_payment_method_to_subscriptions.rb +10 -0
  60. data/db/migrate/20251129222345_create_paddle_rails_payments.rb +24 -0
  61. data/lib/paddle_rails/checkout.rb +181 -0
  62. data/lib/paddle_rails/configuration.rb +121 -0
  63. data/lib/paddle_rails/engine.rb +49 -0
  64. data/lib/paddle_rails/product_sync.rb +176 -0
  65. data/lib/paddle_rails/subscription_sync.rb +303 -0
  66. data/lib/paddle_rails/version.rb +6 -0
  67. data/lib/paddle_rails/webhook_processor.rb +102 -0
  68. data/lib/paddle_rails/webhook_verifier.rb +110 -0
  69. data/lib/paddle_rails.rb +32 -0
  70. data/lib/tasks/paddle_rails_tasks.rake +15 -0
  71. 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
+