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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Configuration class for PaddleRails gem settings.
5
+ #
6
+ # @example Configuring PaddleRails
7
+ # PaddleRails.configure do |config|
8
+ # config.api_key = "your_api_key"
9
+ # config.public_token = "your_public_token"
10
+ # config.subscription_owner_authenticator do
11
+ # current_user || warden.authenticate!(scope: :user)
12
+ # end
13
+ # end
14
+ class Configuration
15
+ # @!attribute subscription_owner_authenticator
16
+ # @return [Proc] The block used to authenticate the subscription owner.
17
+ # Defaults to `current_user || warden.authenticate!(scope: :user)`
18
+ # @!attribute customer_portal_back_path
19
+ # @return [Proc] The block used to generate the back link path shown in
20
+ # the customer portal sidebar. Evaluated in the controller or view
21
+ # context and defaults to `main_app.root_path`.
22
+ # @!attribute api_key
23
+ # @return [String] The Paddle API key. Defaults to ENV["PADDLE_API_KEY"]
24
+ # @!attribute public_token
25
+ # @return [String] The Paddle public token. Defaults to ENV["PADDLE_PUBLIC_TOKEN"]
26
+ # @!attribute environment
27
+ # @return [String] The Paddle environment ("sandbox" or "production"). Defaults to ENV["PADDLE_ENVIRONMENT"] or "sandbox"
28
+ # @!attribute webhook_secret
29
+ # @return [String] The webhook secret key for verifying webhook signatures. Defaults to ENV["PADDLE_WEBHOOK_SECRET"]
30
+ attr_accessor :subscription_owner_authenticator,
31
+ :customer_portal_back_path,
32
+ :api_key,
33
+ :public_token,
34
+ :environment,
35
+ :webhook_secret
36
+
37
+ # Initialize a new Configuration instance with default values.
38
+ #
39
+ # Sets up default authenticator following Doorkeeper pattern and
40
+ # loads API key and public token from environment variables.
41
+ def initialize
42
+ # Default authenticator following Doorkeeper pattern
43
+ @subscription_owner_authenticator = proc do
44
+ current_user || warden.authenticate!(scope: :user)
45
+ end
46
+
47
+ # Default back link in the customer portal sidebar
48
+ @customer_portal_back_path = proc do
49
+ main_app.root_path
50
+ end
51
+
52
+ @api_key = ENV.fetch("PADDLE_API_KEY")
53
+ @public_token = ENV.fetch("PADDLE_PUBLIC_TOKEN")
54
+ @environment = ENV.fetch("PADDLE_ENVIRONMENT", "sandbox")
55
+ @webhook_secret = ENV["PADDLE_WEBHOOK_SECRET"]
56
+ end
57
+
58
+ # Configure the subscription owner authenticator block.
59
+ #
60
+ # @param block [Proc] The block to use for authenticating subscription owners.
61
+ # The block is evaluated in the context of the controller or view.
62
+ # @return [Proc] The configured authenticator block
63
+ #
64
+ # @example
65
+ # config.subscription_owner_authenticator do
66
+ # current_user
67
+ # end
68
+ #
69
+ # @example Multi-tenant setup
70
+ # config.subscription_owner_authenticator do
71
+ # current_tenant
72
+ # end
73
+ def subscription_owner_authenticator(&block)
74
+ @subscription_owner_authenticator = block if block_given?
75
+ @subscription_owner_authenticator
76
+ end
77
+
78
+ # Configure the customer portal back path block.
79
+ #
80
+ # @param block [Proc] The block to use for generating the back link path.
81
+ # The block is evaluated in the context of the controller or view.
82
+ # @return [Proc] The configured back path block
83
+ #
84
+ # @example
85
+ # config.customer_portal_back_path do
86
+ # main_app.dashboard_path
87
+ # end
88
+ def customer_portal_back_path(&block)
89
+ @customer_portal_back_path = block if block_given?
90
+ @customer_portal_back_path
91
+ end
92
+ end
93
+
94
+ class << self
95
+ # Configure PaddleRails settings.
96
+ #
97
+ # @yield [config] Yields the configuration instance
98
+ # @yieldparam config [Configuration] The configuration instance to modify
99
+ # @return [Configuration] The configuration instance
100
+ #
101
+ # @example
102
+ # PaddleRails.configure do |config|
103
+ # config.api_key = "your_api_key"
104
+ # config.subscription_owner_authenticator do
105
+ # current_user
106
+ # end
107
+ # end
108
+ def configure
109
+ yield(configuration) if block_given?
110
+ configuration
111
+ end
112
+
113
+ # Get the current configuration instance.
114
+ #
115
+ # @return [Configuration] The singleton configuration instance
116
+ def configuration
117
+ @configuration ||= Configuration.new
118
+ end
119
+ end
120
+ end
121
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Rails engine for PaddleRails gem.
5
+ #
6
+ # Handles configuration of the Paddle client and makes subscription
7
+ # owner helpers available globally in controllers and views.
8
+ #
9
+ # @see PaddleRails::Configuration
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace PaddleRails
12
+
13
+ # Configure the Paddle client with API key from configuration.
14
+ #
15
+ # Sets up the Paddle gem with environment and API key from
16
+ # PaddleRails configuration, falling back to environment variables.
17
+ initializer "paddle_rails.configuration" do
18
+ Paddle.configure do |config|
19
+ config.environment = ENV.fetch("PADDLE_ENVIRONMENT", "sandbox").to_sym
20
+ config.api_key = PaddleRails.configuration.api_key
21
+ end
22
+ end
23
+
24
+ # Add webhook route to main application
25
+ initializer "paddle_rails.routes" do |app|
26
+ app.routes.prepend do
27
+ post "/paddle_rails/webhooks", to: "paddle_rails/webhooks#create", as: :paddle_rails_webhooks
28
+ end
29
+ end
30
+
31
+ # Prepare helpers for inclusion in controllers and views.
32
+ #
33
+ # Makes {PaddleRails::SubscriptionOwner} and {PaddleRails::SubscriptionOwnerHelper}
34
+ # available both within the engine namespace and globally in all controllers
35
+ # and views.
36
+ config.to_prepare do
37
+ # Include controller concern in engine's ApplicationController
38
+ PaddleRails::ApplicationController.include(PaddleRails::SubscriptionOwner)
39
+
40
+ # Include view helper in engine's ApplicationHelper
41
+ PaddleRails::ApplicationHelper.include(PaddleRails::SubscriptionOwnerHelper)
42
+
43
+ # Make helpers available globally
44
+ ActionController::Base.include(PaddleRails::SubscriptionOwner)
45
+ ActionView::Base.include(PaddleRails::SubscriptionOwnerHelper)
46
+ ActionView::Base.include(PaddleRails::ApplicationHelper)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Service class for syncing Paddle products and prices to local database.
5
+ #
6
+ # Fetches all products and prices from the Paddle API and creates or updates
7
+ # corresponding {Product} and {Price} records locally.
8
+ #
9
+ # @example Sync all products and prices
10
+ # PaddleRails::ProductSync.call
11
+ #
12
+ # @see Product
13
+ # @see Price
14
+ class ProductSync
15
+ # Convenience method to create a new instance and call it.
16
+ #
17
+ # @return [ProductSync] The instance that was called
18
+ def self.call
19
+ new.call
20
+ end
21
+
22
+ # Perform the sync operation.
23
+ #
24
+ # Syncs all products and prices from Paddle to local database.
25
+ #
26
+ # @return [void]
27
+ def call
28
+ sync_products
29
+ sync_prices
30
+ end
31
+
32
+ private
33
+
34
+ # Sync all products from Paddle API.
35
+ #
36
+ # Fetches products in pages of 50 and creates or updates local
37
+ # {Product} records.
38
+ #
39
+ # @return [void]
40
+ # @raise [StandardError] If product sync fails
41
+ def sync_products
42
+ page = 1
43
+ per_page = 50
44
+ loop do
45
+ products = Paddle::Product.list(per_page: per_page, page: page)
46
+ break if products.data.empty?
47
+
48
+ products.data.each do |product|
49
+ sync_product(product)
50
+ end
51
+
52
+ # Check if there are more pages by comparing current page size with total
53
+ break if (page * per_page) >= products.total
54
+ page += 1
55
+ end
56
+ end
57
+
58
+ # Sync a single product to local database.
59
+ #
60
+ # @param product_data [Paddle::Product] The product data from Paddle API
61
+ # @return [void]
62
+ # @raise [StandardError] If product save fails
63
+ def sync_product(product_data)
64
+ product = Product.find_or_initialize_by(paddle_product_id: product_data.id)
65
+
66
+ product.assign_attributes(
67
+ name: product_data.name,
68
+ description: product_data.description,
69
+ status: product_data.status,
70
+ type: product_data.type,
71
+ tax_category: product_data.tax_category,
72
+ image_url: product_data.image_url,
73
+ custom_data: product_data.custom_data
74
+ )
75
+
76
+ product.save!
77
+ rescue StandardError => e
78
+ Rails.logger.error("PaddleRails::ProductSync: Failed to sync product #{product_data.id}: #{e.message}")
79
+ raise
80
+ end
81
+
82
+ # Sync all prices from Paddle API.
83
+ #
84
+ # Fetches prices in pages of 50 and creates or updates local
85
+ # {Price} records.
86
+ #
87
+ # @return [void]
88
+ # @raise [StandardError] If price sync fails
89
+ def sync_prices
90
+ page = 1
91
+ per_page = 50
92
+ loop do
93
+ prices = Paddle::Price.list(per_page: per_page, page: page)
94
+ break if prices.data.empty?
95
+
96
+ prices.data.each do |price|
97
+ sync_price(price)
98
+ end
99
+
100
+ # Check if there are more pages by comparing current page size with total
101
+ break if (page * per_page) >= prices.total
102
+ page += 1
103
+ end
104
+ end
105
+
106
+ # Sync a single price to local database.
107
+ #
108
+ # @param price_data [Paddle::Price] The price data from Paddle API
109
+ # @return [void]
110
+ # @raise [StandardError] If price save fails
111
+ def sync_price(price_data)
112
+ # Find the product by product_id
113
+ product = Product.find_by(paddle_product_id: price_data.product_id)
114
+ unless product
115
+ Rails.logger.warn("PaddleRails::ProductSync: Skipping price #{price_data.id} - product #{price_data.product_id} not found locally")
116
+ return
117
+ end
118
+
119
+ price = Price.find_or_initialize_by(paddle_price_id: price_data.id)
120
+
121
+ # Extract billing cycle info (OpenStruct from paddle gem)
122
+ billing_interval = price_data.billing_cycle&.interval
123
+ billing_interval_count = price_data.billing_cycle&.frequency
124
+
125
+ # Extract unit price info (OpenStruct from paddle gem)
126
+ unit_price_amount = price_data.unit_price&.amount&.to_i
127
+ currency_code = price_data.unit_price&.currency_code
128
+
129
+ # Extract trial period (store as JSON, extract days if available)
130
+ trial_period = price_data.trial_period
131
+ trial_days = extract_trial_days(trial_period)
132
+
133
+ # Extract quantity constraints
134
+ quantity_minimum = price_data.quantity&.minimum
135
+ quantity_maximum = price_data.quantity&.maximum
136
+
137
+ price.assign_attributes(
138
+ product: product,
139
+ name: price_data.name,
140
+ description: price_data.description,
141
+ status: price_data.status,
142
+ type: price_data.type,
143
+ currency: currency_code,
144
+ unit_price: unit_price_amount,
145
+ billing_interval: billing_interval,
146
+ billing_interval_count: billing_interval_count,
147
+ trial_days: trial_days,
148
+ trial_period: trial_period,
149
+ tax_mode: price_data.tax_mode,
150
+ quantity_minimum: quantity_minimum,
151
+ quantity_maximum: quantity_maximum,
152
+ custom_data: price_data.custom_data
153
+ )
154
+
155
+ price.save!
156
+ rescue StandardError => e
157
+ Rails.logger.error("PaddleRails::ProductSync: Failed to sync price #{price_data.id}: #{e.message}")
158
+ raise
159
+ end
160
+
161
+ # Extract trial days from trial period data.
162
+ #
163
+ # @param trial_period [OpenStruct, nil] Trial period data from Paddle API
164
+ # @return [Integer, nil] Number of trial days, or nil if not applicable
165
+ def extract_trial_days(trial_period)
166
+ return nil unless trial_period
167
+
168
+ # trial_period is OpenStruct from paddle gem
169
+ # Common structure: interval: "day", frequency: 14
170
+ return trial_period.frequency if trial_period.interval == "day"
171
+
172
+ nil
173
+ end
174
+ end
175
+ end
176
+
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Service for synchronizing Paddle subscription data to the local database.
5
+ #
6
+ # Handles resolving the owner from custom_data and creating/updating the
7
+ # PaddleRails::Subscription record.
8
+ class SubscriptionSync
9
+ # Sync a subscription by fetching it from the Paddle API.
10
+ #
11
+ # @param subscription_id [String] The Paddle subscription ID (sub_...)
12
+ # @return [PaddleRails::Subscription] The synced subscription record
13
+ def self.sync_from_paddle(subscription_id)
14
+ # Use the Paddle gem to retrieve the subscription
15
+ subscription = Paddle::Subscription.retrieve(id: subscription_id)
16
+
17
+ # Convert to a hash compatible with our sync logic
18
+ # Note: The Paddle gem returns OpenStruct-like objects, so we need to handle that.
19
+ # Assuming Paddle gem returns an object where attributes are methods.
20
+ # We can probably pass the object directly to sync_from_payload if it responds to hash-like methods
21
+ # or convert it. For safety, let's pass the raw response if possible, or the object.
22
+
23
+ # If the gem returns a response object that wraps the data:
24
+ payload = subscription.respond_to?(:attributes) ? subscription.attributes : subscription.to_h
25
+
26
+ new(payload).sync
27
+ end
28
+
29
+ # Sync a subscription from a webhook or API payload.
30
+ #
31
+ # @param payload [Hash, Object] The subscription data from Paddle
32
+ # @return [PaddleRails::Subscription] The synced subscription record
33
+ def self.sync_from_payload(payload)
34
+ new(payload).sync
35
+ end
36
+
37
+ def initialize(payload)
38
+ # Normalize payload to a hash with string keys
39
+ @payload = payload.is_a?(Hash) ? payload.stringify_keys : payload.to_h.stringify_keys
40
+ end
41
+
42
+ def sync
43
+ paddle_subscription_id = @payload["id"]
44
+ return nil unless paddle_subscription_id
45
+
46
+ # Find existing subscription or initialize new one
47
+ subscription = Subscription.find_or_initialize_by(paddle_subscription_id: paddle_subscription_id)
48
+
49
+ # Extract attributes
50
+ status = @payload["status"]
51
+
52
+ # Dates
53
+ current_period = @payload["current_billing_period"]
54
+ current_period_end_at = current_period&.dig("ends_at")
55
+
56
+ # Scheduled Change
57
+ scheduled_change = @payload["scheduled_change"]
58
+ scheduled_cancelation_at = if scheduled_change&.dig("action") == "cancel"
59
+ scheduled_change["effective_at"]
60
+ end
61
+
62
+ # Items
63
+ items = @payload["items"] || []
64
+
65
+ # Resolve Owner
66
+ owner = resolve_owner
67
+
68
+ # If owner is missing for a new subscription, we can't save it properly
69
+ # unless we allow orphans. For now, we log an error if owner is missing.
70
+ if owner.nil? && subscription.new_record?
71
+ Rails.logger.error("PaddleRails::SubscriptionSync: Could not resolve owner for subscription #{paddle_subscription_id}. Custom data: #{@payload['custom_data']}")
72
+ # We might want to create it anyway if we can link it later, but validation requires owner.
73
+ return nil
74
+ end
75
+
76
+ # Update attributes
77
+ subscription.status = status
78
+ subscription.current_period_end_at = current_period_end_at
79
+ subscription.scheduled_cancelation_at = scheduled_cancelation_at
80
+ subscription.owner = owner if owner # Only update owner if resolved (don't overwrite with nil)
81
+ subscription.raw_payload = @payload
82
+
83
+ subscription.save!
84
+
85
+ # Sync items
86
+ sync_items(subscription, items)
87
+
88
+ subscription
89
+ end
90
+
91
+ private
92
+
93
+ def resolve_owner
94
+ custom_data = @payload["custom_data"] || {}
95
+ owner_sgid = custom_data["owner_sgid"]
96
+
97
+ return nil unless owner_sgid
98
+
99
+ # Use GlobalID to locate the owner
100
+ GlobalID::Locator.locate_signed(owner_sgid, for: "paddle_rails_owner")
101
+ rescue => e
102
+ Rails.logger.error("PaddleRails::SubscriptionSync: Error resolving owner: #{e.message}")
103
+ nil
104
+ end
105
+
106
+ # Sync subscription items from the payload.
107
+ #
108
+ # @param subscription [PaddleRails::Subscription] The subscription to sync items for
109
+ # @param items_payload [Array] Array of item hashes from Paddle
110
+ # @return [void]
111
+ def sync_items(subscription, items_payload)
112
+ return unless items_payload.is_a?(Array)
113
+
114
+ # Track which price IDs we've seen in this sync
115
+ seen_price_ids = []
116
+
117
+ items_payload.each do |item_data|
118
+ price_data = item_data["price"] || item_data.dig("price")
119
+ next unless price_data
120
+
121
+ paddle_price_id = price_data["id"]
122
+ next unless paddle_price_id
123
+
124
+ # Find the local price record
125
+ price_record = find_price_by_paddle_id(paddle_price_id)
126
+ next unless price_record
127
+
128
+ seen_price_ids << price_record.id
129
+
130
+ # Find or initialize the subscription item by subscription and price
131
+ subscription_item = subscription.items.find_or_initialize_by(
132
+ price_id: price_record.id
133
+ )
134
+
135
+ # Set product reference (through price)
136
+ subscription_item.product = price_record.product
137
+
138
+ # Update attributes
139
+ subscription_item.quantity = item_data["quantity"] || 1
140
+ subscription_item.status = item_data["status"]
141
+ subscription_item.recurring = item_data["recurring"] == true
142
+
143
+ subscription_item.save!
144
+ end
145
+
146
+ # Delete items that are no longer in the payload (full sync)
147
+ subscription.items.where.not(price_id: seen_price_ids).destroy_all
148
+ end
149
+
150
+ # Find a Price by its Paddle price ID.
151
+ #
152
+ # @param paddle_price_id [String] The Paddle price ID
153
+ # @return [PaddleRails::Price, nil]
154
+ def find_price_by_paddle_id(paddle_price_id)
155
+ Price.find_by(paddle_price_id: paddle_price_id)
156
+ end
157
+
158
+ # Sync payment method from a transaction payload.
159
+ #
160
+ # Called when processing transaction.completed webhooks.
161
+ # Extracts payment method details from the transaction's payments array
162
+ # and updates the associated subscription.
163
+ #
164
+ # @param transaction_payload [Hash] The transaction data from Paddle
165
+ # @return [PaddleRails::Subscription, nil] The updated subscription or nil
166
+ def self.sync_payment_method_from_transaction(transaction_payload)
167
+ payload = transaction_payload.is_a?(Hash) ? transaction_payload.stringify_keys : transaction_payload.to_h.stringify_keys
168
+
169
+ subscription_id = payload["subscription_id"]
170
+ return nil unless subscription_id
171
+
172
+ subscription = Subscription.find_by(paddle_subscription_id: subscription_id)
173
+ return nil unless subscription
174
+
175
+ # Get the first successful payment from the payments array
176
+ payments = payload["payments"] || []
177
+ payment = payments.find { |p| p["status"] == "captured" } || payments.first
178
+ return nil unless payment
179
+
180
+ payment_method_id = payment["payment_method_id"]
181
+ method_details = payment["method_details"]
182
+
183
+ return nil unless method_details
184
+
185
+ # Extract payment method details
186
+ details = extract_payment_details_from_transaction(method_details)
187
+
188
+ subscription.payment_method_id = payment_method_id
189
+ subscription.payment_method_type = method_details["type"]
190
+ subscription.payment_method_details = details
191
+ subscription.save!
192
+
193
+ subscription
194
+ rescue StandardError => e
195
+ Rails.logger.error("PaddleRails::SubscriptionSync: Error syncing payment method from transaction: #{e.message}")
196
+ nil
197
+ end
198
+
199
+ # Extract payment method details from transaction method_details.
200
+ #
201
+ # Handles the nested structure from transaction.completed webhooks:
202
+ # {
203
+ # "type": "card",
204
+ # "card": {
205
+ # "type": "visa",
206
+ # "last4": "4242",
207
+ # "expiry_year": 2028,
208
+ # "expiry_month": 12,
209
+ # "cardholder_name": "..."
210
+ # }
211
+ # }
212
+ #
213
+ # @param method_details [Hash] The method_details from payment
214
+ # @return [Hash] Extracted details for storage
215
+ def self.extract_payment_details_from_transaction(method_details)
216
+ return {} unless method_details.is_a?(Hash)
217
+
218
+ details = { type: method_details["type"] }
219
+
220
+ card_data = method_details["card"]
221
+ if card_data.is_a?(Hash)
222
+ details[:card] = {
223
+ brand: card_data["type"]&.upcase, # In transaction payload, card brand is in "type" field
224
+ last4: card_data["last4"],
225
+ expiry_month: card_data["expiry_month"],
226
+ expiry_year: card_data["expiry_year"],
227
+ cardholder_name: card_data["cardholder_name"]
228
+ }.compact
229
+ end
230
+
231
+ details
232
+ end
233
+
234
+ # Sync a payment from a transaction.completed webhook payload.
235
+ #
236
+ # Creates or updates a Payment record with transaction data.
237
+ #
238
+ # @param transaction_payload [Hash] The transaction data from Paddle
239
+ # @return [PaddleRails::Payment, nil] The synced payment record or nil
240
+ def self.sync_payment(transaction_payload)
241
+ payload = transaction_payload.is_a?(Hash) ? transaction_payload.stringify_keys : transaction_payload.to_h.stringify_keys
242
+
243
+ paddle_transaction_id = payload["id"]
244
+ return nil unless paddle_transaction_id
245
+
246
+ subscription_id = payload["subscription_id"]
247
+ return nil unless subscription_id
248
+
249
+ subscription = Subscription.find_by(paddle_subscription_id: subscription_id)
250
+ return nil unless subscription
251
+
252
+ # Resolve owner from custom_data or use subscription's owner
253
+ owner = resolve_owner_from_payload(payload) || subscription.owner
254
+ return nil unless owner
255
+
256
+ # Extract totals from details
257
+ details = payload["details"] || {}
258
+ totals = details["totals"] || {}
259
+
260
+ # Find or initialize payment
261
+ payment = PaddleRails::Payment.find_or_initialize_by(paddle_transaction_id: paddle_transaction_id)
262
+
263
+ # Update attributes
264
+ payment.subscription = subscription
265
+ payment.owner = owner
266
+ payment.invoice_id = payload["invoice_id"]
267
+ payment.invoice_number = payload["invoice_number"]
268
+ payment.status = payload["status"]
269
+ payment.origin = payload["origin"]
270
+ payment.total = totals["total"]&.to_i || totals[:total]&.to_i
271
+ payment.tax = totals["tax"]&.to_i || totals[:tax]&.to_i
272
+ payment.subtotal = totals["subtotal"]&.to_i || totals[:subtotal]&.to_i
273
+ payment.currency = totals["currency_code"] || totals[:currency_code] || payload["currency_code"]
274
+ payment.billed_at = payload["billed_at"] || payload["billed_at"]
275
+ payment.details = details
276
+ payment.raw_payload = payload
277
+
278
+ payment.save!
279
+ payment
280
+ rescue StandardError => e
281
+ Rails.logger.error("PaddleRails::SubscriptionSync: Error syncing payment: #{e.message}")
282
+ Rails.logger.error(e.backtrace.join("\n"))
283
+ nil
284
+ end
285
+
286
+ # Resolve owner from transaction payload's custom_data.
287
+ #
288
+ # @param payload [Hash] The transaction payload
289
+ # @return [Object, nil] The owner object or nil
290
+ def self.resolve_owner_from_payload(payload)
291
+ custom_data = payload["custom_data"] || {}
292
+ owner_sgid = custom_data["owner_sgid"]
293
+
294
+ return nil unless owner_sgid
295
+
296
+ GlobalID::Locator.locate_signed(owner_sgid, for: "paddle_rails_owner")
297
+ rescue => e
298
+ Rails.logger.error("PaddleRails::SubscriptionSync: Error resolving owner from payload: #{e.message}")
299
+ nil
300
+ end
301
+ end
302
+ end
303
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Current version of the PaddleRails gem.
5
+ VERSION = "0.1.0"
6
+ end