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,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
|
+
|