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
+ 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,4 @@
1
+ module PaddleRails
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -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,6 @@
1
+ module PaddleRails
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -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,5 @@
1
+ module PaddleRails
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -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
+