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
|
+
module ApplicationHelper
|
|
5
|
+
# Returns the path to the billing dashboard.
|
|
6
|
+
#
|
|
7
|
+
# This is a convenience helper that provides a clean, global way to
|
|
8
|
+
# reference the PaddleRails billing portal root path.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# <%= link_to "Manage Billing", billing_dashboard_path %>
|
|
12
|
+
#
|
|
13
|
+
# @return [String] the path to the billing dashboard
|
|
14
|
+
def billing_dashboard_path
|
|
15
|
+
paddle_rails.root_path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the URL to the billing dashboard.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# <%= link_to "Manage Billing", billing_dashboard_url %>
|
|
22
|
+
#
|
|
23
|
+
# @return [String] the URL to the billing dashboard
|
|
24
|
+
def billing_dashboard_url
|
|
25
|
+
paddle_rails.root_url
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns an SVG icon for the given card brand.
|
|
29
|
+
#
|
|
30
|
+
# @param brand [String] The card brand (e.g., "VISA", "MASTERCARD", "AMEX")
|
|
31
|
+
# @param size [String] CSS classes for sizing (default: "w-8 h-5")
|
|
32
|
+
# @return [String] The SVG markup (html_safe)
|
|
33
|
+
def payment_method_icon(brand, size: "w-8 h-5")
|
|
34
|
+
svg = case brand&.to_s&.upcase
|
|
35
|
+
when "VISA"
|
|
36
|
+
<<~SVG
|
|
37
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
38
|
+
<rect width="48" height="32" rx="4" fill="#1A1F71"/>
|
|
39
|
+
<path d="M19.5 21H17L18.8 11H21.3L19.5 21Z" fill="white"/>
|
|
40
|
+
<path d="M28.1 11.2C27.5 11 26.7 10.7 25.7 10.7C23.2 10.7 21.4 12 21.4 13.9C21.4 15.3 22.7 16.1 23.7 16.6C24.7 17.1 25 17.4 25 17.9C25 18.6 24.1 18.9 23.3 18.9C22.2 18.9 21.6 18.8 20.7 18.4L20.3 18.2L19.9 20.7C20.6 21 21.8 21.3 23 21.3C25.7 21.3 27.4 20 27.4 18C27.4 16.9 26.7 16 25.2 15.3C24.3 14.8 23.7 14.5 23.7 14C23.7 13.5 24.3 13 25.4 13C26.3 13 27 13.2 27.5 13.4L27.8 13.5L28.1 11.2Z" fill="white"/>
|
|
41
|
+
<path d="M32.9 11H30.9C30.3 11 29.8 11.2 29.5 11.8L25.8 21H28.5L29 19.6H32.3L32.6 21H35L32.9 11ZM29.8 17.6C30 17 30.9 14.5 30.9 14.5C30.9 14.5 31.1 14 31.3 13.6L31.5 14.4C31.5 14.4 32 16.7 32.1 17.5H29.8V17.6Z" fill="white"/>
|
|
42
|
+
<path d="M15.4 11L12.9 17.8L12.6 16.3C12.1 14.7 10.6 13 8.9 12.1L11.2 21H14L18.2 11H15.4Z" fill="white"/>
|
|
43
|
+
<path d="M11.1 11H6.9L6.8 11.2C10.1 12 12.3 14 13.1 16.3L12.2 11.9C12.1 11.3 11.7 11.1 11.1 11Z" fill="#F9A51A"/>
|
|
44
|
+
</svg>
|
|
45
|
+
SVG
|
|
46
|
+
when "MASTERCARD"
|
|
47
|
+
<<~SVG
|
|
48
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
49
|
+
<rect width="48" height="32" rx="4" fill="#000"/>
|
|
50
|
+
<circle cx="18" cy="16" r="8" fill="#EB001B"/>
|
|
51
|
+
<circle cx="30" cy="16" r="8" fill="#F79E1B"/>
|
|
52
|
+
<path d="M24 10.4C25.8 11.9 27 14 27 16C27 18 25.8 20.1 24 21.6C22.2 20.1 21 18 21 16C21 14 22.2 11.9 24 10.4Z" fill="#FF5F00"/>
|
|
53
|
+
</svg>
|
|
54
|
+
SVG
|
|
55
|
+
when "AMEX", "AMERICAN EXPRESS"
|
|
56
|
+
<<~SVG
|
|
57
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
58
|
+
<rect width="48" height="32" rx="4" fill="#006FCF"/>
|
|
59
|
+
<path d="M10 16.5L12.5 11H15L18.5 19H16L15.3 17.5H12.2L11.5 19H9L10 16.5ZM13.7 13L12.8 15.5H14.7L13.7 13Z" fill="white"/>
|
|
60
|
+
<path d="M19 11H22L24 15L26 11H29V19H27V14L24.5 19H23.5L21 14V19H19V11Z" fill="white"/>
|
|
61
|
+
<path d="M30 11H37V12.8H32V14H36.8V15.8H32V17.2H37V19H30V11Z" fill="white"/>
|
|
62
|
+
<path d="M38 11H40.5L42 14L43.5 11H46L43 15.5L46 20H43.5L42 17L40.5 20H38L41 15.5L38 11Z" fill="white"/>
|
|
63
|
+
</svg>
|
|
64
|
+
SVG
|
|
65
|
+
when "DISCOVER"
|
|
66
|
+
<<~SVG
|
|
67
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
68
|
+
<rect width="48" height="32" rx="4" fill="#fff"/>
|
|
69
|
+
<rect x="0.5" y="0.5" width="47" height="31" rx="3.5" stroke="#E5E7EB"/>
|
|
70
|
+
<path d="M0 16H24C24 22 29 28 36 28H48V32H0V16Z" fill="#F48024"/>
|
|
71
|
+
<circle cx="30" cy="16" r="6" fill="#F48024"/>
|
|
72
|
+
<text x="8" y="18" font-family="Arial" font-size="8" font-weight="bold" fill="#000">DISCOVER</text>
|
|
73
|
+
</svg>
|
|
74
|
+
SVG
|
|
75
|
+
when "DINERS", "DINERS CLUB"
|
|
76
|
+
<<~SVG
|
|
77
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
78
|
+
<rect width="48" height="32" rx="4" fill="#0079BE"/>
|
|
79
|
+
<circle cx="24" cy="16" r="9" fill="white"/>
|
|
80
|
+
<path d="M19 16C19 13.2 20.8 10.9 23.3 10.2V21.8C20.8 21.1 19 18.8 19 16Z" fill="#0079BE"/>
|
|
81
|
+
<path d="M29 16C29 18.8 27.2 21.1 24.7 21.8V10.2C27.2 10.9 29 13.2 29 16Z" fill="#0079BE"/>
|
|
82
|
+
</svg>
|
|
83
|
+
SVG
|
|
84
|
+
when "JCB"
|
|
85
|
+
<<~SVG
|
|
86
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
87
|
+
<rect width="48" height="32" rx="4" fill="#fff"/>
|
|
88
|
+
<rect x="0.5" y="0.5" width="47" height="31" rx="3.5" stroke="#E5E7EB"/>
|
|
89
|
+
<rect x="8" y="6" width="10" height="20" rx="2" fill="#0E4C96"/>
|
|
90
|
+
<rect x="19" y="6" width="10" height="20" rx="2" fill="#E01536"/>
|
|
91
|
+
<rect x="30" y="6" width="10" height="20" rx="2" fill="#00A14F"/>
|
|
92
|
+
<text x="10" y="19" font-family="Arial" font-size="6" font-weight="bold" fill="white">J</text>
|
|
93
|
+
<text x="22" y="19" font-family="Arial" font-size="6" font-weight="bold" fill="white">C</text>
|
|
94
|
+
<text x="33" y="19" font-family="Arial" font-size="6" font-weight="bold" fill="white">B</text>
|
|
95
|
+
</svg>
|
|
96
|
+
SVG
|
|
97
|
+
when "UNIONPAY"
|
|
98
|
+
<<~SVG
|
|
99
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
100
|
+
<rect width="48" height="32" rx="4" fill="#1A4788"/>
|
|
101
|
+
<path d="M12 6H20L18 26H10L12 6Z" fill="#E21836"/>
|
|
102
|
+
<path d="M18 6H28L26 26H16L18 6Z" fill="#00447C"/>
|
|
103
|
+
<path d="M26 6H36L34 26H24L26 6Z" fill="#007B84"/>
|
|
104
|
+
</svg>
|
|
105
|
+
SVG
|
|
106
|
+
else
|
|
107
|
+
# Generic card icon
|
|
108
|
+
<<~SVG
|
|
109
|
+
<svg class="#{size}" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
110
|
+
<rect width="48" height="32" rx="4" fill="#6B7280"/>
|
|
111
|
+
<rect x="6" y="10" width="12" height="8" rx="1" fill="#9CA3AF"/>
|
|
112
|
+
<rect x="6" y="22" width="20" height="2" rx="1" fill="#9CA3AF"/>
|
|
113
|
+
<rect x="28" y="22" width="8" height="2" rx="1" fill="#9CA3AF"/>
|
|
114
|
+
</svg>
|
|
115
|
+
SVG
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
svg.html_safe
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module PaddleRails
|
|
2
|
+
module SubscriptionOwnerHelper
|
|
3
|
+
def subscription_owner
|
|
4
|
+
authenticator = PaddleRails.configuration.subscription_owner_authenticator
|
|
5
|
+
return nil unless authenticator
|
|
6
|
+
|
|
7
|
+
instance_eval(&authenticator)
|
|
8
|
+
rescue StandardError => e
|
|
9
|
+
Rails.logger.error("PaddleRails::SubscriptionOwnerHelper: Error authenticating subscription owner: #{e.message}")
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
# Background job for processing webhook events asynchronously.
|
|
5
|
+
#
|
|
6
|
+
# This job finds the webhook event, marks it as processing, calls the
|
|
7
|
+
# WebhookProcessor to handle the event, and updates the status accordingly.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# PaddleRails::ProcessWebhookJob.perform_later(webhook_event.id)
|
|
11
|
+
class ProcessWebhookJob < ApplicationJob
|
|
12
|
+
queue_as :default
|
|
13
|
+
|
|
14
|
+
# Process a webhook event.
|
|
15
|
+
#
|
|
16
|
+
# @param event_id [Integer] The ID of the WebhookEvent to process
|
|
17
|
+
def perform(event_id)
|
|
18
|
+
event = WebhookEvent.find_by(id: event_id)
|
|
19
|
+
unless event
|
|
20
|
+
Rails.logger.error("PaddleRails::ProcessWebhookJob: WebhookEvent #{event_id} not found")
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
event.mark_as_processing!
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
WebhookProcessor.process(event)
|
|
28
|
+
event.mark_as_processed!
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
error_message = "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
31
|
+
event.mark_as_failed!(error_message)
|
|
32
|
+
Rails.logger.error("PaddleRails::ProcessWebhookJob: Failed to process webhook #{event_id}: #{error_message}")
|
|
33
|
+
raise # Re-raise to trigger ActiveJob retry mechanism
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
module Subscribable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
has_many :paddle_subscriptions, as: :owner, class_name: "PaddleRails::Subscription", dependent: :destroy
|
|
9
|
+
has_many :payments, as: :owner, class_name: "PaddleRails::Payment", dependent: :destroy
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns all subscriptions for the model instance
|
|
13
|
+
# @return [Array]
|
|
14
|
+
def subscriptions
|
|
15
|
+
paddle_subscriptions
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the current active subscription or nil
|
|
19
|
+
# @return [PaddleRails::Subscription, nil]
|
|
20
|
+
def subscription
|
|
21
|
+
paddle_subscriptions.active.order(created_at: :desc).first
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns true if the model has a current active subscription
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
def subscription?
|
|
27
|
+
subscription.present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Create a Paddle checkout for this model instance
|
|
31
|
+
#
|
|
32
|
+
# @param paddle_price_id [String] The Paddle Price ID
|
|
33
|
+
# @param custom_data [Hash] Optional custom data to include
|
|
34
|
+
# @param options [Hash] Additional options for Paddle::Transaction.create
|
|
35
|
+
# @return [Paddle::Transaction]
|
|
36
|
+
def create_paddle_checkout(paddle_price_id:, custom_data: {}, **options)
|
|
37
|
+
PaddleRails::Checkout.create(
|
|
38
|
+
owner: self,
|
|
39
|
+
paddle_price_id: paddle_price_id,
|
|
40
|
+
custom_data: custom_data,
|
|
41
|
+
**options
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
# Model representing a completed payment/transaction from Paddle.
|
|
5
|
+
#
|
|
6
|
+
# Stores payment data parsed from transaction.completed webhooks.
|
|
7
|
+
class Payment < ApplicationRecord
|
|
8
|
+
self.table_name = "paddle_rails_payments"
|
|
9
|
+
|
|
10
|
+
belongs_to :subscription, class_name: "PaddleRails::Subscription"
|
|
11
|
+
belongs_to :owner, polymorphic: true
|
|
12
|
+
|
|
13
|
+
validates :paddle_transaction_id, presence: true, uniqueness: true
|
|
14
|
+
validates :status, presence: true
|
|
15
|
+
|
|
16
|
+
scope :completed, -> { where(status: "completed") }
|
|
17
|
+
scope :recent, -> { order(billed_at: :desc) }
|
|
18
|
+
|
|
19
|
+
# Returns the amount in the payment's currency as a decimal.
|
|
20
|
+
# @return [Float]
|
|
21
|
+
def amount_in_currency
|
|
22
|
+
return 0.0 unless total
|
|
23
|
+
total / 100.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns a description of the payment based on line items.
|
|
27
|
+
# @return [String]
|
|
28
|
+
def description
|
|
29
|
+
return "Payment" unless details.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
line_items = details["line_items"] || details[:line_items] || []
|
|
32
|
+
return "Payment" if line_items.empty?
|
|
33
|
+
|
|
34
|
+
# Get the first line item's product name
|
|
35
|
+
first_item = line_items.first
|
|
36
|
+
product = first_item&.dig("product") || first_item&.dig(:product)
|
|
37
|
+
product_name = product&.dig("name") || product&.dig(:name)
|
|
38
|
+
|
|
39
|
+
product_name || "Payment"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class Price < ApplicationRecord
|
|
5
|
+
self.table_name = "paddle_rails_prices"
|
|
6
|
+
self.inheritance_column = nil # Disable STI since we use 'type' for Paddle price type
|
|
7
|
+
|
|
8
|
+
belongs_to :product, class_name: "PaddleRails::Product"
|
|
9
|
+
|
|
10
|
+
validates :paddle_price_id, presence: true, uniqueness: true
|
|
11
|
+
|
|
12
|
+
scope :active, -> { joins(:product).where(paddle_rails_products: { status: "active" }).where(status: "active") }
|
|
13
|
+
scope :for_currency, ->(currency) { where(currency: currency) }
|
|
14
|
+
|
|
15
|
+
# Whether this price is active.
|
|
16
|
+
#
|
|
17
|
+
# Mirrors the `active` scope but operates on a single record.
|
|
18
|
+
#
|
|
19
|
+
# @return [Boolean]
|
|
20
|
+
def active?
|
|
21
|
+
status == "active"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class Product < ApplicationRecord
|
|
5
|
+
self.table_name = "paddle_rails_products"
|
|
6
|
+
self.inheritance_column = nil # Disable STI since we use 'type' for Paddle product type
|
|
7
|
+
|
|
8
|
+
has_many :prices, class_name: "PaddleRails::Price", foreign_key: "product_id", dependent: :destroy
|
|
9
|
+
|
|
10
|
+
validates :paddle_product_id, presence: true, uniqueness: true
|
|
11
|
+
|
|
12
|
+
scope :active, -> { where(status: "active") }
|
|
13
|
+
scope :archived, -> { where(status: "archived") }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class Subscription < ApplicationRecord
|
|
5
|
+
self.table_name = "paddle_rails_subscriptions"
|
|
6
|
+
|
|
7
|
+
# Statuses
|
|
8
|
+
ACTIVE = "active"
|
|
9
|
+
TRIALING = "trialing"
|
|
10
|
+
PAST_DUE = "past_due"
|
|
11
|
+
PAUSED = "paused"
|
|
12
|
+
CANCELED = "canceled"
|
|
13
|
+
|
|
14
|
+
belongs_to :owner, polymorphic: true
|
|
15
|
+
has_many :items, class_name: "PaddleRails::SubscriptionItem", dependent: :destroy
|
|
16
|
+
has_many :prices, through: :items, source: :price
|
|
17
|
+
has_many :products, through: :prices, source: :product
|
|
18
|
+
has_many :payments, class_name: "PaddleRails::Payment", dependent: :destroy
|
|
19
|
+
|
|
20
|
+
validates :paddle_subscription_id, presence: true, uniqueness: true
|
|
21
|
+
validates :status, presence: true
|
|
22
|
+
|
|
23
|
+
scope :active, -> { where(status: ACTIVE) }
|
|
24
|
+
scope :trialing, -> { where(status: TRIALING) }
|
|
25
|
+
scope :past_due, -> { where(status: PAST_DUE) }
|
|
26
|
+
scope :paused, -> { where(status: PAUSED) }
|
|
27
|
+
scope :canceled, -> { where(status: CANCELED) }
|
|
28
|
+
|
|
29
|
+
# Returns true if the subscription is active.
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def active?
|
|
32
|
+
status == ACTIVE
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns true if the subscription is trialing.
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def trialing?
|
|
38
|
+
status == TRIALING
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns true if the subscription is paused.
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def paused?
|
|
44
|
+
status == PAUSED
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns true if the subscription is canceled.
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def canceled?
|
|
50
|
+
status == CANCELED
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns true if the subscription is currently in a trial period.
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def in_trial?
|
|
56
|
+
trial_ends_at.present? && trial_ends_at > Time.current
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns true if the current period is active (not expired).
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def current_period_active?
|
|
62
|
+
current_period_end_at.present? && current_period_end_at > Time.current
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns true if the subscription is scheduled for cancellation at the end of the period.
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def scheduled_for_cancellation?
|
|
68
|
+
scheduled_cancelation_at.present? && scheduled_cancelation_at > Time.current
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the primary product for this subscription.
|
|
72
|
+
# Uses the first recurring item's product, or falls back to the first item's product.
|
|
73
|
+
# @return [PaddleRails::Product, nil]
|
|
74
|
+
def product
|
|
75
|
+
# Try to get product from first recurring item
|
|
76
|
+
first_recurring_item = items.find_by(recurring: true)
|
|
77
|
+
return first_recurring_item&.product if first_recurring_item
|
|
78
|
+
|
|
79
|
+
# Fallback to first item
|
|
80
|
+
items.first&.product
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Alias for backward compatibility
|
|
84
|
+
alias_method :plan, :product
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
# Model representing an individual item within a subscription.
|
|
5
|
+
#
|
|
6
|
+
# A subscription can have multiple items (e.g., base plan + addons).
|
|
7
|
+
# Each item links to a specific price and tracks its quantity and status.
|
|
8
|
+
class SubscriptionItem < ApplicationRecord
|
|
9
|
+
self.table_name = "paddle_rails_subscription_items"
|
|
10
|
+
|
|
11
|
+
belongs_to :subscription, class_name: "PaddleRails::Subscription"
|
|
12
|
+
belongs_to :price, class_name: "PaddleRails::Price"
|
|
13
|
+
belongs_to :product, class_name: "PaddleRails::Product"
|
|
14
|
+
# Alias for backward compatibility
|
|
15
|
+
alias_method :plan, :product
|
|
16
|
+
|
|
17
|
+
validates :subscription_id, presence: true
|
|
18
|
+
validates :price_id, presence: true
|
|
19
|
+
validates :quantity, presence: true, numericality: { greater_than: 0 }
|
|
20
|
+
validates :status, presence: true
|
|
21
|
+
|
|
22
|
+
# Returns true if this item is active.
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def active?
|
|
25
|
+
status == "active"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns true if this item is recurring.
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def recurring?
|
|
31
|
+
recurring == true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
# Model for storing incoming webhook events from Paddle.
|
|
5
|
+
#
|
|
6
|
+
# Webhooks are stored with their raw payload and processed asynchronously
|
|
7
|
+
# via background jobs to ensure reliability and allow for replayability.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# event = PaddleRails::WebhookEvent.create!(
|
|
11
|
+
# external_id: "evt_123",
|
|
12
|
+
# event_type: "subscription.created",
|
|
13
|
+
# payload: { ... },
|
|
14
|
+
# status: "pending"
|
|
15
|
+
# )
|
|
16
|
+
class WebhookEvent < ApplicationRecord
|
|
17
|
+
self.table_name = "paddle_rails_webhook_events"
|
|
18
|
+
|
|
19
|
+
# Status values
|
|
20
|
+
PENDING = "pending"
|
|
21
|
+
PROCESSING = "processing"
|
|
22
|
+
PROCESSED = "processed"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
|
|
25
|
+
validates :external_id, presence: true, uniqueness: true
|
|
26
|
+
validates :event_type, presence: true
|
|
27
|
+
validates :payload, presence: true
|
|
28
|
+
validates :status, presence: true, inclusion: { in: [PENDING, PROCESSING, PROCESSED, FAILED] }
|
|
29
|
+
|
|
30
|
+
scope :pending, -> { where(status: PENDING) }
|
|
31
|
+
scope :processing, -> { where(status: PROCESSING) }
|
|
32
|
+
scope :processed, -> { where(status: PROCESSED) }
|
|
33
|
+
scope :failed, -> { where(status: FAILED) }
|
|
34
|
+
|
|
35
|
+
# Mark the event as processing
|
|
36
|
+
def mark_as_processing!
|
|
37
|
+
update!(status: PROCESSING)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mark the event as processed
|
|
41
|
+
def mark_as_processed!
|
|
42
|
+
update!(status: PROCESSED, processed_at: Time.current)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Mark the event as failed with an error message
|
|
46
|
+
def mark_as_failed!(error_message)
|
|
47
|
+
update!(status: FAILED, processing_errors: error_message)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class PaymentPresenter
|
|
5
|
+
attr_reader :payment
|
|
6
|
+
|
|
7
|
+
delegate :id, :status, :invoice_id, :invoice_number, :paddle_transaction_id, to: :payment
|
|
8
|
+
|
|
9
|
+
def initialize(payment)
|
|
10
|
+
@payment = payment
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the formatted date of the payment.
|
|
14
|
+
# @return [String]
|
|
15
|
+
def date
|
|
16
|
+
return "N/A" unless payment.billed_at
|
|
17
|
+
payment.billed_at.strftime("%b %d, %Y")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns a description of the payment.
|
|
21
|
+
# @return [String]
|
|
22
|
+
def description
|
|
23
|
+
payment.description
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the formatted amount with currency symbol.
|
|
27
|
+
# @return [String]
|
|
28
|
+
def amount
|
|
29
|
+
return "N/A" unless payment.total
|
|
30
|
+
amount_value = payment.amount_in_currency
|
|
31
|
+
currency_symbol = currency_symbol_for(payment.currency)
|
|
32
|
+
"#{currency_symbol}#{format('%.2f', amount_value.abs)}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the status label for display.
|
|
36
|
+
# @return [String]
|
|
37
|
+
def status_label
|
|
38
|
+
case payment.status
|
|
39
|
+
when "completed"
|
|
40
|
+
"Paid"
|
|
41
|
+
when "pending"
|
|
42
|
+
"Pending"
|
|
43
|
+
when "failed"
|
|
44
|
+
"Failed"
|
|
45
|
+
else
|
|
46
|
+
payment.status.titleize
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns true if the payment is a credit (negative amount).
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def credit?
|
|
53
|
+
payment.total && payment.total < 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the status badge class based on payment status.
|
|
57
|
+
# @return [String]
|
|
58
|
+
def status_badge_class
|
|
59
|
+
case payment.status
|
|
60
|
+
when "completed"
|
|
61
|
+
credit? ? "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20" : "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20"
|
|
62
|
+
when "pending"
|
|
63
|
+
"bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20"
|
|
64
|
+
when "failed"
|
|
65
|
+
"bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/20"
|
|
66
|
+
else
|
|
67
|
+
"bg-slate-50 text-slate-700 ring-1 ring-inset ring-slate-600/20"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns true if invoice is available.
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def has_invoice?
|
|
74
|
+
paddle_transaction_id.present?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Returns the currency symbol for a given currency code.
|
|
80
|
+
# @param currency [String] The currency code (e.g., "USD", "EUR")
|
|
81
|
+
# @return [String]
|
|
82
|
+
def currency_symbol_for(currency)
|
|
83
|
+
case currency&.upcase
|
|
84
|
+
when "EUR"
|
|
85
|
+
"€"
|
|
86
|
+
when "GBP"
|
|
87
|
+
"£"
|
|
88
|
+
when "USD"
|
|
89
|
+
"$"
|
|
90
|
+
else
|
|
91
|
+
currency || "$"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|