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,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Presenter for products on the onboarding page.
5
+ #
6
+ # Wraps a {Product} and its active {Price} records
7
+ # and exposes formatted data for the onboarding view so the template
8
+ # can stay mostly declarative and free from business logic.
9
+ #
10
+ # @example Building presenters in the controller
11
+ # products = Product.active.includes(:prices)
12
+ # @products = products.each_with_index.map do |product, index|
13
+ # PaddleRails::ProductPresenter.new(product, index: index)
14
+ # end
15
+ #
16
+ # @example Using presenter methods in the view
17
+ # <% @products.each do |product| %>
18
+ # <%= product.name %>
19
+ # <%= product.primary_label %> <!-- e.g. "29 EUR / month" -->
20
+ # <% end %>
21
+ class ProductPresenter
22
+ attr_reader :product, :index
23
+
24
+ # Initialize a new presenter.
25
+ #
26
+ # @param product [PaddleRails::Product] the product being presented
27
+ # @param index [Integer] zero-based index of the product in the list
28
+ # @param default_currency [String] fallback currency code, defaults to "EUR"
29
+ def initialize(product, index: 0, default_currency: "EUR")
30
+ @product = product
31
+ @index = index
32
+ @default_currency = default_currency
33
+ end
34
+
35
+ # Display name for the product.
36
+ #
37
+ # Falls back to `"Product #{index + 1}"` when the record has no name.
38
+ #
39
+ # @return [String]
40
+ def name
41
+ product.name.presence || "Product #{index + 1}"
42
+ end
43
+
44
+ # Description text for the product.
45
+ #
46
+ # @return [String, nil]
47
+ def description
48
+ product.description
49
+ end
50
+
51
+ # All active prices for the product, ordered by unit price.
52
+ #
53
+ # @return [ActiveRecord::Relation<PaddleRails::Price>]
54
+ def prices
55
+ @prices ||= product.prices.active.order(:unit_price)
56
+ end
57
+
58
+ # Whether the product has any active prices.
59
+ #
60
+ # @return [Boolean]
61
+ def any_prices?
62
+ prices.any?
63
+ end
64
+
65
+ # Whether the product has more than one active price.
66
+ #
67
+ # @return [Boolean]
68
+ def multiple_prices?
69
+ prices.many?
70
+ end
71
+
72
+ # The primary price used for the default selection.
73
+ #
74
+ # @return [PaddleRails::Price, nil]
75
+ def primary_price
76
+ prices.first
77
+ end
78
+
79
+ # The formatted primary amount, converted from minor units.
80
+ #
81
+ # @return [Integer] whole amount (e.g. 29 for 29.00)
82
+ def primary_amount
83
+ amount_for(primary_price)
84
+ end
85
+
86
+ # The currency code for the primary price.
87
+ #
88
+ # @return [String] e.g. "EUR"
89
+ def primary_currency
90
+ currency_for(primary_price)
91
+ end
92
+
93
+ # The human-readable billing interval for the primary price.
94
+ #
95
+ # @return [String] e.g. "month", "12 months", or "one-time"
96
+ def primary_billing
97
+ billing_for(primary_price)
98
+ end
99
+
100
+ # Full label for the primary price.
101
+ #
102
+ # @return [String] e.g. "29 EUR / month"
103
+ def primary_label
104
+ label_for(primary_price)
105
+ end
106
+
107
+ # Paddle price ID of the primary price.
108
+ #
109
+ # @return [String, nil]
110
+ def primary_price_id
111
+ primary_price&.paddle_price_id
112
+ end
113
+
114
+ # Trial days for the primary price, if any.
115
+ #
116
+ # @return [Integer, nil]
117
+ def trial_days
118
+ primary_price&.trial_days
119
+ end
120
+
121
+ # Returns an array of [paddle_price_id, label] pairs
122
+ # for all active prices on this product.
123
+ #
124
+ # @return [Array<Array(String, String)>]
125
+ def price_options
126
+ prices.map { |price| [price.paddle_price_id, label_for(price)] }
127
+ end
128
+
129
+ private
130
+
131
+ # Convert a price's unit_price (stored in minor units) to an integer amount.
132
+ #
133
+ # @param price [PaddleRails::Price, nil]
134
+ # @return [Integer]
135
+ def amount_for(price)
136
+ return 0 unless price&.unit_price
137
+
138
+ (price.unit_price / 100.0).to_i
139
+ end
140
+
141
+ # Resolve a price's currency or fall back to the default.
142
+ #
143
+ # @param price [PaddleRails::Price, nil]
144
+ # @return [String]
145
+ def currency_for(price)
146
+ (price&.currency || @default_currency).upcase
147
+ end
148
+
149
+ # Build a human-readable billing interval from Paddle data.
150
+ #
151
+ # @param price [PaddleRails::Price, nil]
152
+ # @return [String]
153
+ def billing_for(price)
154
+ return "one-time" unless price&.billing_interval.present?
155
+
156
+ interval = price.billing_interval.to_s.downcase
157
+ count = (price.billing_interval_count || 1).to_i
158
+
159
+ case interval
160
+ when "month"
161
+ count == 1 ? "month" : "#{count} months"
162
+ when "year"
163
+ count == 1 ? "year" : "#{count} years"
164
+ else
165
+ price.billing_interval
166
+ end
167
+ end
168
+
169
+ # Build a full label for a price.
170
+ #
171
+ # @param price [PaddleRails::Price, nil]
172
+ # @return [String]
173
+ def label_for(price)
174
+ "#{amount_for(price)} #{currency_for(price)} / #{billing_for(price)}"
175
+ end
176
+ end
177
+ end
178
+
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ class SubscriptionPresenter
5
+ attr_reader :subscription
6
+
7
+ delegate :status, :items, to: :subscription
8
+
9
+ def initialize(subscription)
10
+ @subscription = subscription
11
+ end
12
+
13
+ def product_name
14
+ subscription.product&.name || "Subscription"
15
+ end
16
+
17
+ def product_initial
18
+ (subscription.product&.name&.chars&.first || "S").upcase
19
+ end
20
+
21
+ def status_label
22
+ status.titleize
23
+ end
24
+
25
+ def amount
26
+ total_cents = items.sum do |item|
27
+ next 0 unless item.price && item.price.unit_price
28
+ item.price.unit_price * item.quantity
29
+ end
30
+ (total_cents / 100.0)
31
+ end
32
+
33
+ def currency
34
+ # Assuming all items have the same currency, which is standard for a single subscription.
35
+ # We can check the first item's price currency.
36
+ first_item_with_price = items.find { |i| i.price.present? }
37
+ return "" unless first_item_with_price
38
+ first_item_with_price.price.currency.upcase
39
+ end
40
+
41
+ def formatted_amount
42
+ # We'll let the view handle number_to_currency for now to keep view helpers available.
43
+ amount
44
+ end
45
+
46
+ def currency_symbol
47
+ return "$" if currency.blank?
48
+ currency == "EUR" ? "€" : "$"
49
+ end
50
+
51
+ def interval_label
52
+ # Use the interval from the first recurring item, or fallback to the first item.
53
+ # Usually all items in a subscription share the billing cycle.
54
+ item = items.find { |i| i.recurring? && i.price.present? } || items.find { |i| i.price.present? }
55
+ return "" unless item&.price
56
+
57
+ interval = item.price.billing_interval
58
+ count = item.price.billing_interval_count || 1
59
+
60
+ case interval
61
+ when "month" then (count == 1 ? "month" : "#{count} months")
62
+ when "year" then (count == 1 ? "year" : "#{count} years")
63
+ else interval
64
+ end
65
+ end
66
+
67
+ def billing_date_title
68
+ if subscription.scheduled_for_cancellation?
69
+ "Scheduled for cancellation"
70
+ elsif subscription.canceled?
71
+ "Canceled on"
72
+ else
73
+ "Next billing date"
74
+ end
75
+ end
76
+
77
+ def billing_date
78
+ date = if subscription.scheduled_for_cancellation?
79
+ subscription.scheduled_cancelation_at
80
+ elsif subscription.canceled?
81
+ subscription.updated_at
82
+ else
83
+ subscription.current_period_end_at
84
+ end
85
+
86
+ date&.strftime("%B %d, %Y") || "N/A"
87
+ end
88
+
89
+ def price_display_text
90
+ return "Custom plan" if amount.zero?
91
+ # View will handle currency formatting
92
+ nil
93
+ end
94
+
95
+ def has_price?
96
+ amount > 0
97
+ end
98
+
99
+ def payment_method_type
100
+ subscription.payment_method_type
101
+ end
102
+
103
+ def has_payment_method?
104
+ subscription.payment_method_id.present?
105
+ end
106
+
107
+ def card_brand
108
+ return nil unless has_payment_method?
109
+ details = subscription.payment_method_details || {}
110
+ card = details["card"] || details[:card] || {}
111
+ (card["brand"] || card[:brand] || "").upcase
112
+ end
113
+
114
+ def card_last4
115
+ return nil unless has_payment_method?
116
+ details = subscription.payment_method_details || {}
117
+ card = details["card"] || details[:card] || {}
118
+ card["last4"] || card[:last4]
119
+ end
120
+
121
+ def card_expiration
122
+ return nil unless has_payment_method?
123
+ details = subscription.payment_method_details || {}
124
+ card = details["card"] || details[:card] || {}
125
+ month = card["expiry_month"] || card[:expiry_month]
126
+ year = card["expiry_year"] || card[:expiry_year]
127
+
128
+ return nil unless month && year
129
+
130
+ # Format as MM/YYYY
131
+ "#{month.to_s.rjust(2, '0')}/#{year}"
132
+ end
133
+
134
+ def payment_method_icon
135
+ # Return the brand name for display in the icon area
136
+ # This can be styled with CSS to show appropriate icons
137
+ card_brand.presence || "CARD"
138
+ end
139
+
140
+ private
141
+
142
+ # Removed def price as we now calculate totals across all items
143
+ end
144
+ end
145
+
@@ -0,0 +1,170 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Paddle rails</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600&display=swap" rel="stylesheet">
13
+
14
+ <%= stylesheet_link_tag "paddle_rails/application", media: "all" %>
15
+ <%= stylesheet_link_tag "paddle_rails/tailwind", media: "all" %>
16
+
17
+ <style>
18
+ body {
19
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
20
+ font-weight: 400;
21
+ }
22
+ </style>
23
+ </head>
24
+ <body class="bg-slate-50">
25
+ <div class="flex flex-col md:flex-row md:h-screen md:overflow-hidden">
26
+ <!-- Sidebar - On top on mobile, fixed on left on desktop -->
27
+ <aside class="w-full md:w-80 bg-gradient-to-b from-slate-900 via-slate-900 to-slate-800 flex-shrink-0 flex flex-col relative overflow-hidden">
28
+ <!-- Decorative background elements -->
29
+ <div class="absolute inset-0 overflow-hidden pointer-events-none">
30
+ <div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-500/10 rounded-full blur-3xl"></div>
31
+ <div class="absolute top-1/2 -left-32 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl"></div>
32
+ <div class="absolute -bottom-32 right-0 w-48 h-48 bg-emerald-500/10 rounded-full blur-3xl"></div>
33
+ </div>
34
+
35
+ <div class="p-6 md:p-8 flex-1 overflow-y-auto relative z-10">
36
+ <!-- Icon badge -->
37
+ <div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center mb-6 shadow-lg shadow-indigo-500/25">
38
+ <svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
40
+ </svg>
41
+ </div>
42
+
43
+ <!-- Main Heading -->
44
+ <h1 class="text-2xl md:text-[1.75rem] font-semibold text-white mb-3 leading-tight tracking-tight">
45
+ Billing & subscription
46
+ </h1>
47
+
48
+ <!-- Description -->
49
+ <p class="text-slate-400 text-sm leading-relaxed mb-8">
50
+ Manage your plan, view payment history, download invoices, and update payment methods.
51
+ </p>
52
+
53
+ <!-- Quick Links -->
54
+ <div class="space-y-1 mb-8">
55
+ <p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Quick links</p>
56
+ <a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-white/5 transition-all duration-200 group">
57
+ <svg class="w-4 h-4 text-slate-500 group-hover:text-indigo-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
58
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
59
+ </svg>
60
+ <span class="text-sm font-medium">Invoices & receipts</span>
61
+ </a>
62
+ <a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-white/5 transition-all duration-200 group">
63
+ <svg class="w-4 h-4 text-slate-500 group-hover:text-indigo-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
64
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
65
+ </svg>
66
+ <span class="text-sm font-medium">Payment methods</span>
67
+ </a>
68
+ <a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-white/5 transition-all duration-200 group">
69
+ <svg class="w-4 h-4 text-slate-500 group-hover:text-indigo-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
71
+ </svg>
72
+ <span class="text-sm font-medium">Help & support</span>
73
+ </a>
74
+ </div>
75
+
76
+ <!-- Back Link -->
77
+ <% back_path_proc = PaddleRails.configuration.customer_portal_back_path %>
78
+ <% back_path = instance_exec(&back_path_proc) if back_path_proc %>
79
+ <% if back_path.present? %>
80
+ <%= link_to back_path, class: "inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-white/5 border border-white/10 text-slate-300 hover:text-white hover:bg-white/10 hover:border-white/20 text-sm font-medium transition-all duration-200" do %>
81
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
82
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
83
+ </svg>
84
+ Back to app
85
+ <% end %>
86
+ <% end %>
87
+ </div>
88
+
89
+ <!-- Footer with User Info -->
90
+ <div class="p-6 md:px-8 md:py-5 border-t border-white/5 relative z-10">
91
+ <% if subscription_owner %>
92
+ <div class="flex items-center justify-between">
93
+ <div class="flex-1 min-w-0">
94
+ <% if subscription_owner.respond_to?(:name) && subscription_owner.name.present? %>
95
+ <p class="text-sm font-medium text-white leading-snug truncate"><%= subscription_owner.name %></p>
96
+ <% if subscription_owner.respond_to?(:email) %>
97
+ <p class="text-xs text-slate-400 leading-snug truncate mt-0.5"><%= subscription_owner.email %></p>
98
+ <% end %>
99
+ <% elsif subscription_owner.respond_to?(:email) %>
100
+ <p class="text-sm font-medium text-white leading-snug truncate"><%= subscription_owner.email %></p>
101
+ <% end %>
102
+ </div>
103
+ <div class="flex items-center gap-1.5 ml-4">
104
+ <div class="w-5 h-5 rounded bg-emerald-500/20 flex items-center justify-center">
105
+ <svg class="w-3 h-3 text-emerald-400" fill="currentColor" viewBox="0 0 20 20">
106
+ <path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
107
+ </svg>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ <% else %>
112
+ <div class="flex items-center gap-2">
113
+ <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center">
114
+ <svg class="w-3 h-3 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
115
+ <path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
116
+ </svg>
117
+ </div>
118
+ <span class="text-xs text-slate-500">Secure billing portal</span>
119
+ </div>
120
+ <% end %>
121
+ </div>
122
+ </aside>
123
+
124
+ <!-- Scrollable Content Area -->
125
+ <main class="flex-1 overflow-y-auto bg-white">
126
+ <!-- Trust Header -->
127
+ <div class="bg-gradient-to-r from-slate-50 to-slate-100 border-b border-slate-200/60">
128
+ <div class="max-w-6xl mx-auto px-4 md:px-8 py-3 flex items-center justify-between">
129
+ <div class="flex items-center gap-6 text-xs text-slate-600">
130
+ <span class="flex items-center gap-1.5">
131
+ <svg class="w-4 h-4 text-emerald-600" fill="currentColor" viewBox="0 0 20 20">
132
+ <path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
133
+ </svg>
134
+ <span class="font-medium">Secure connection</span>
135
+ </span>
136
+ <span class="hidden sm:flex items-center gap-1.5">
137
+ <svg class="w-4 h-4 text-emerald-600" fill="currentColor" viewBox="0 0 20 20">
138
+ <path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
139
+ </svg>
140
+ <span>256-bit SSL encrypted</span>
141
+ </span>
142
+ </div>
143
+ <div class="flex items-center gap-2 text-xs text-slate-500">
144
+ <span>Powered by</span>
145
+ <span class="font-semibold text-slate-700">Paddle</span>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Flash Notifications -->
151
+ <% if flash[:notice] || flash[:alert] %>
152
+ <div class="px-4 md:px-8 pt-4 max-w-6xl mx-auto">
153
+ <% if flash[:notice] %>
154
+ <div class="bg-emerald-50 border border-emerald-200 text-emerald-800 px-4 py-3 rounded-lg mb-4" role="alert">
155
+ <p class="text-sm font-medium"><%= raw flash[:notice] %></p>
156
+ </div>
157
+ <% end %>
158
+ <% if flash[:alert] %>
159
+ <div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4" role="alert">
160
+ <p class="text-sm font-medium"><%= raw flash[:alert] %></p>
161
+ </div>
162
+ <% end %>
163
+ </div>
164
+ <% end %>
165
+
166
+ <%= yield %>
167
+ </main>
168
+ </div>
169
+ </body>
170
+ </html>
@@ -0,0 +1,128 @@
1
+ <div class="min-h-screen flex flex-col justify-center p-4 md:p-8 max-w-4xl mx-auto">
2
+ <!-- Header -->
3
+ <div class="text-center mb-12">
4
+ <h1 class="text-3xl md:text-4xl lg:text-5xl font-semibold tracking-tight text-gray-900 mb-4">
5
+ Complete your purchase
6
+ </h1>
7
+ <p class="text-gray-600 text-lg">Review your order and complete the checkout process</p>
8
+ </div>
9
+
10
+ <!-- Checkout container where Paddle.js will render the inline checkout -->
11
+ <div id="checkout-container" class="checkout-container">
12
+ <!-- Paddle.js will automatically inject the checkout here when the transaction ID is present -->
13
+ </div>
14
+
15
+ <!-- Processing state (hidden by default) -->
16
+ <div id="processing-state" class="hidden text-center">
17
+ <div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mb-4"></div>
18
+ <p class="text-gray-600 text-lg">Processing your order...</p>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- script loaded here to make sure it's loaded after the page is rendered -->
23
+ <script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
24
+ <script type="text/javascript">
25
+ Paddle.Environment.set("<%= PaddleRails.configuration.environment || 'sandbox' %>");
26
+ Paddle.Initialize({
27
+ token: "<%= PaddleRails.configuration.public_token %>",
28
+ checkout: {
29
+ settings: {
30
+ displayMode: "inline",
31
+ variant: "dark",
32
+ frameTarget: "checkout-container",
33
+ frameInitialHeight: "450",
34
+ frameStyle: "width: 100%; min-width: 312px; background-color: transparent; border: none;"
35
+ }
36
+ },
37
+ eventCallback: function(data) {
38
+ console.log(data);
39
+ if (data.name == "checkout.loaded") {
40
+ // Checkout loaded successfully
41
+ };
42
+ if (data.name == "checkout.completed") {
43
+ // Hide checkout frame
44
+ var checkoutContainer = document.getElementById("checkout-container");
45
+ if (checkoutContainer) {
46
+ checkoutContainer.style.display = "none";
47
+ }
48
+
49
+ // Show processing state
50
+ var processingState = document.getElementById("processing-state");
51
+ if (processingState) {
52
+ processingState.classList.remove("hidden");
53
+ }
54
+
55
+ // Extract transaction ID from URL
56
+ var urlParams = new URLSearchParams(window.location.search);
57
+ var transactionId = urlParams.get("_ptxn");
58
+
59
+ if (transactionId) {
60
+ // Start polling for subscription status
61
+ pollSubscriptionStatus(transactionId);
62
+ } else {
63
+ console.error("Transaction ID not found in URL");
64
+ // Fallback: redirect after a delay
65
+ setTimeout(function() {
66
+ window.location.href = "<%= paddle_rails.root_path %>";
67
+ }, 2000);
68
+ }
69
+ };
70
+ }
71
+ });
72
+
73
+ // Poll the subscription status endpoint
74
+ function pollSubscriptionStatus(transactionId) {
75
+ var pollInterval = 1000; // Poll every second
76
+ var maxAttempts = 30; // Maximum 30 attempts (30 seconds)
77
+ var attempts = 0;
78
+
79
+ var poll = function() {
80
+ attempts++;
81
+
82
+ // Construct the status check URL
83
+ var baseUrl = "<%= paddle_rails.check_transaction_status_path('PLACEHOLDER') %>";
84
+ var statusUrl = baseUrl.replace('PLACEHOLDER', encodeURIComponent(transactionId));
85
+ fetch(statusUrl, {
86
+ method: "GET",
87
+ headers: {
88
+ "Accept": "application/json",
89
+ "X-Requested-With": "XMLHttpRequest"
90
+ },
91
+ credentials: "same-origin"
92
+ })
93
+ .then(function(response) {
94
+ return response.json();
95
+ })
96
+ .then(function(data) {
97
+ if (data.status === "active") {
98
+ // Subscription is active, redirect to dashboard
99
+ if (data.redirect_url) {
100
+ window.location.href = data.redirect_url;
101
+ } else {
102
+ window.location.href = "<%= paddle_rails.root_path %>";
103
+ }
104
+ } else if (attempts >= maxAttempts) {
105
+ // Max attempts reached, redirect anyway
106
+ console.warn("Max polling attempts reached, redirecting...");
107
+ window.location.href = "<%= paddle_rails.root_path %>";
108
+ } else {
109
+ // Still pending, poll again
110
+ setTimeout(poll, pollInterval);
111
+ }
112
+ })
113
+ .catch(function(error) {
114
+ console.error("Error polling subscription status:", error);
115
+ // On error, retry after a delay
116
+ if (attempts < maxAttempts) {
117
+ setTimeout(poll, pollInterval);
118
+ } else {
119
+ // Max attempts reached, redirect anyway
120
+ window.location.href = "<%= paddle_rails.root_path %>";
121
+ }
122
+ });
123
+ };
124
+
125
+ // Start polling
126
+ poll();
127
+ }
128
+ </script>