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,286 @@
1
+ <!-- Plan Management Section -->
2
+ <section class="mb-8 md:mb-12">
3
+ <div class="mb-6">
4
+ <h2 class="text-lg font-medium text-slate-900">Change plan</h2>
5
+ <p class="text-sm text-slate-500 mt-0.5">Upgrade, downgrade, or change your billing cycle</p>
6
+ </div>
7
+
8
+ <% if @products.any? %>
9
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-5">
10
+ <% current_product = @subscription.subscription.product %>
11
+ <% current_product_id = current_product&.paddle_product_id %>
12
+ <% current_primary_price = current_product&.prices&.active&.order(:unit_price)&.first %>
13
+ <% current_price_amount = current_primary_price ? (current_primary_price.unit_price / 100.0) : 0 %>
14
+ <% current_subscription = @subscription.subscription %>
15
+ <% current_price_id = current_subscription.items.find_by(recurring: true)&.price&.paddle_price_id || current_subscription.items.first&.price&.paddle_price_id %>
16
+ <% highest_price = @products.map { |p| p.primary_price&.unit_price || 0 }.max %>
17
+
18
+ <% @products.each_with_index do |product, index| %>
19
+ <% next unless product.any_prices? %>
20
+ <% is_current = product.product.paddle_product_id == current_product_id %>
21
+ <% product_primary_price = product.primary_price %>
22
+ <% product_price_amount = product_primary_price ? (product_primary_price.unit_price / 100.0) : 0 %>
23
+ <% is_upgrade = product_price_amount > current_price_amount %>
24
+ <% is_downgrade = product_price_amount < current_price_amount %>
25
+ <% is_current_price = product_primary_price&.paddle_price_id == current_price_id %>
26
+ <% is_popular = !is_current && product_primary_price&.unit_price == highest_price %>
27
+
28
+ <div class="relative bg-white rounded-xl shadow-sm border-2 <%= is_current ? 'border-indigo-500 ring-1 ring-indigo-500/20' : 'border-slate-200/60 hover:border-indigo-300 hover:shadow-md' %> p-6 transition-all duration-200 flex flex-col" <%= "data-current-price-id=\"#{current_price_id}\"".html_safe if is_current %>>
29
+
30
+ <!-- Badges -->
31
+ <div class="absolute -top-3 left-4 right-4 flex justify-between">
32
+ <% if is_current %>
33
+ <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-indigo-600 text-white shadow-sm">
34
+ <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
35
+ <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
36
+ </svg>
37
+ Current plan
38
+ </span>
39
+ <% elsif is_popular %>
40
+ <span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-sm">
41
+ <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
42
+ <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
43
+ </svg>
44
+ Popular
45
+ </span>
46
+ <% else %>
47
+ <span></span>
48
+ <% end %>
49
+ </div>
50
+
51
+ <div class="mb-4 flex-grow pt-2">
52
+ <h3 class="text-lg font-semibold text-slate-900 mb-3"><%= product.name %></h3>
53
+ <div class="mb-1">
54
+ <span class="text-3xl font-bold text-slate-900"><%= product.primary_amount %></span>
55
+ <span class="text-slate-500 text-sm ml-1"><%= product.primary_currency %> / <%= product.primary_billing %></span>
56
+ </div>
57
+ <p class="text-xs text-slate-400 mb-4">excl. tax</p>
58
+ <% if product.description.present? %>
59
+ <p class="text-sm text-slate-600 leading-relaxed"><%= product.description %></p>
60
+ <% end %>
61
+
62
+ <!-- Billing options (monthly / yearly etc.) -->
63
+ <% if product.multiple_prices? %>
64
+ <div class="mt-4">
65
+ <label class="block text-xs font-medium text-slate-500 uppercase tracking-wider mb-1.5">Billing cycle</label>
66
+ <select
67
+ class="block w-full rounded-lg border-slate-300 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 transition-colors"
68
+ data-price-select="true"
69
+ >
70
+ <% product.price_options.each_with_index do |(price_id, label), idx| %>
71
+ <option
72
+ value="<%= price_id %>"
73
+ data-price-label="<%= label %>"
74
+ <%= "selected" if (is_current && price_id == current_price_id) || (!is_current && idx.zero?) %>
75
+ >
76
+ <%= label %>
77
+ </option>
78
+ <% end %>
79
+ </select>
80
+ </div>
81
+ <% end %>
82
+ </div>
83
+
84
+ <div class="mt-auto pt-4">
85
+ <% if is_current && is_current_price && !product.multiple_prices? %>
86
+ <button class="w-full px-4 py-2.5 bg-slate-100 text-slate-500 text-sm font-medium rounded-lg cursor-not-allowed" disabled>
87
+ Current plan
88
+ </button>
89
+ <% else %>
90
+ <form action="<%= change_subscription_plan_path %>" method="post" data-form="true" id="change-plan-form-<%= product.product.paddle_product_id %>" class="w-full">
91
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
92
+ <%= hidden_field_tag :paddle_price_id, (is_current && current_price_id) ? current_price_id : product.primary_price_id, data: { price_input: true } %>
93
+ <% if is_current && is_current_price %>
94
+ <button type="button" class="w-full px-4 py-2.5 bg-slate-100 text-slate-500 text-sm font-medium rounded-lg cursor-not-allowed" disabled data-plan-change-button="true" data-product-name="<%= product.name %>" data-is-upgrade="<%= is_upgrade %>">
95
+ Current plan
96
+ </button>
97
+ <% else %>
98
+ <% button_text = is_current ? "Change billing interval" : (is_upgrade ? "Upgrade" : "Downgrade") %>
99
+ <% if is_upgrade %>
100
+ <button type="button" class="w-full px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-all duration-200 active:scale-[0.98] shadow-sm" data-plan-change-button="true" data-product-name="<%= product.name %>" data-is-upgrade="<%= is_upgrade %>">
101
+ <%= button_text %>
102
+ </button>
103
+ <% else %>
104
+ <button type="button" class="w-full px-4 py-2.5 border border-slate-300 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50 transition-all duration-200 active:scale-[0.98]" data-plan-change-button="true" data-product-name="<%= product.name %>" data-is-upgrade="<%= is_upgrade %>">
105
+ <%= button_text %>
106
+ </button>
107
+ <% end %>
108
+ <% end %>
109
+ </form>
110
+ <% end %>
111
+ </div>
112
+ </div>
113
+ <% end %>
114
+ </div>
115
+ <% else %>
116
+ <div class="bg-white rounded-xl shadow-sm border border-slate-200/60 p-12 text-center">
117
+ <div class="w-14 h-14 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-4">
118
+ <svg class="w-7 h-7 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
119
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
120
+ </svg>
121
+ </div>
122
+ <h3 class="text-sm font-medium text-slate-900 mb-1">No plans available</h3>
123
+ <p class="text-sm text-slate-500">There are no subscription plans available at this time.</p>
124
+ </div>
125
+ <% end %>
126
+ </section>
127
+
128
+ <!-- Confirmation Modal -->
129
+ <div id="change-plan-modal" class="hidden fixed inset-0 bg-slate-900/50 backdrop-blur-sm overflow-y-auto h-full w-full z-50 transition-opacity">
130
+ <div class="relative top-20 mx-auto p-6 max-w-md shadow-2xl rounded-2xl bg-white">
131
+ <div class="text-center">
132
+ <div class="flex items-center justify-center w-14 h-14 mx-auto bg-indigo-100 rounded-full mb-5">
133
+ <svg class="w-7 h-7 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
134
+ <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" />
135
+ </svg>
136
+ </div>
137
+ <h3 class="text-xl font-semibold text-slate-900 mb-2" id="modal-title">Confirm plan change</h3>
138
+ <div class="mt-3 space-y-3">
139
+ <p class="text-sm text-slate-600" id="modal-message">
140
+ Are you sure you want to change your plan?
141
+ </p>
142
+ <div class="bg-slate-50 rounded-lg p-3">
143
+ <p class="text-xs text-slate-600" id="modal-proration">
144
+ Your payment will be prorated immediately based on your remaining billing period.
145
+ </p>
146
+ </div>
147
+ <p class="text-xs text-indigo-600 font-medium hidden" id="modal-credits">
148
+ Any amount already paid will be added as credits and automatically used for future payments.
149
+ </p>
150
+ </div>
151
+ <div class="flex items-center gap-3 mt-6">
152
+ <button id="modal-cancel" class="flex-1 px-4 py-2.5 bg-slate-100 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-200 transition-all duration-200 active:scale-[0.98]">
153
+ Cancel
154
+ </button>
155
+ <button id="modal-confirm" class="flex-1 px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-all duration-200 active:scale-[0.98] shadow-sm">
156
+ Confirm change
157
+ </button>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <script>
164
+ document.addEventListener("DOMContentLoaded", function() {
165
+ var modal = document.getElementById("change-plan-modal");
166
+ var confirmButton = document.getElementById("modal-confirm");
167
+ var cancelButton = document.getElementById("modal-cancel");
168
+ var modalTitle = document.getElementById("modal-title");
169
+ var modalMessage = document.getElementById("modal-message");
170
+ var modalProration = document.getElementById("modal-proration");
171
+ var modalCredits = document.getElementById("modal-credits");
172
+ var pendingForm = null;
173
+
174
+ // Initialize button states for current products with multiple prices
175
+ document.querySelectorAll("[data-current-price-id]").forEach(function(card) {
176
+ var currentPriceId = card.dataset.currentPriceId;
177
+ if (!currentPriceId) return;
178
+
179
+ var select = card.querySelector("[data-price-select]");
180
+ var button = card.querySelector("[data-plan-change-button]");
181
+ var hiddenField = card.querySelector("[data-price-input]");
182
+
183
+ if (select && button && hiddenField) {
184
+ var selectedPriceId = select.value;
185
+ if (selectedPriceId === currentPriceId) {
186
+ button.disabled = true;
187
+ button.textContent = "Current plan";
188
+ button.className = "w-full px-4 py-2.5 bg-slate-100 text-slate-500 text-sm font-medium rounded-lg cursor-not-allowed";
189
+ }
190
+ }
191
+ });
192
+
193
+ // Handle price select changes
194
+ document.querySelectorAll("[data-price-select]").forEach(function(select) {
195
+ select.addEventListener("change", function(event) {
196
+ var option = select.selectedOptions[0];
197
+ if (!option) return;
198
+
199
+ var card = select.closest("div[class*='bg-white']");
200
+ if (!card) return;
201
+
202
+ var hiddenField = card.querySelector("[data-price-input]");
203
+ var button = card.querySelector("[data-plan-change-button]");
204
+ var currentPriceId = card.dataset.currentPriceId;
205
+ var selectedPriceId = option.value;
206
+
207
+ if (hiddenField) hiddenField.value = selectedPriceId;
208
+
209
+ // If this is the current product, update button state based on selected price
210
+ if (currentPriceId && button) {
211
+ if (selectedPriceId === currentPriceId) {
212
+ button.disabled = true;
213
+ button.textContent = "Current plan";
214
+ button.className = "w-full px-4 py-2.5 bg-slate-100 text-slate-500 text-sm font-medium rounded-lg cursor-not-allowed";
215
+ } else {
216
+ button.disabled = false;
217
+ button.textContent = "Change billing interval";
218
+ button.className = "w-full px-4 py-2.5 border border-slate-300 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-50 transition-all duration-200 active:scale-[0.98]";
219
+ }
220
+ }
221
+ });
222
+ });
223
+
224
+ // Handle plan change button clicks
225
+ document.querySelectorAll("[data-plan-change-button]").forEach(function(button) {
226
+ button.addEventListener("click", function(event) {
227
+ event.preventDefault();
228
+
229
+ var form = button.closest("form[data-form]");
230
+ if (!form) return;
231
+
232
+ var productName = button.dataset.productName;
233
+ var isUpgrade = button.dataset.isUpgrade === "true";
234
+ var card = button.closest("div[class*='bg-white']");
235
+ var currentPriceId = card ? card.dataset.currentPriceId : null;
236
+ var form = button.closest("form[data-form]");
237
+ var hiddenField = form ? form.querySelector("[data-price-input]") : null;
238
+ var selectedPriceId = hiddenField ? hiddenField.value : null;
239
+ var isChangingBilling = currentPriceId && selectedPriceId && selectedPriceId !== currentPriceId;
240
+
241
+ var action;
242
+ if (isChangingBilling) {
243
+ action = "change the billing cycle for";
244
+ } else {
245
+ action = isUpgrade ? "upgrade to" : "downgrade to";
246
+ }
247
+
248
+ modalTitle.textContent = "Confirm plan change";
249
+ modalMessage.textContent = "Are you sure you want to " + action + " " + productName + "?";
250
+
251
+ // Show credits message for downgrades, hide for upgrades
252
+ if (isUpgrade) {
253
+ modalCredits.classList.add("hidden");
254
+ modalProration.textContent = "Your payment will be prorated immediately based on your remaining billing period.";
255
+ } else {
256
+ modalCredits.classList.remove("hidden");
257
+ modalProration.textContent = "Your payment will be prorated immediately based on your remaining billing period.";
258
+ }
259
+
260
+ pendingForm = form;
261
+ modal.classList.remove("hidden");
262
+ });
263
+ });
264
+
265
+ // Handle modal confirmation
266
+ confirmButton.addEventListener("click", function() {
267
+ if (pendingForm) {
268
+ pendingForm.submit();
269
+ }
270
+ });
271
+
272
+ // Handle modal cancellation
273
+ cancelButton.addEventListener("click", function() {
274
+ modal.classList.add("hidden");
275
+ pendingForm = null;
276
+ });
277
+
278
+ // Close modal when clicking outside
279
+ modal.addEventListener("click", function(event) {
280
+ if (event.target === modal) {
281
+ modal.classList.add("hidden");
282
+ pendingForm = null;
283
+ }
284
+ });
285
+ });
286
+ </script>
@@ -0,0 +1,66 @@
1
+ <!-- Current Subscription Section -->
2
+ <section class="mb-8 md:mb-12">
3
+ <div class="mb-6">
4
+ <h2 class="text-lg font-medium text-slate-900">Current subscription</h2>
5
+ <p class="text-sm text-slate-500 mt-0.5">Manage your active plan and billing cycle</p>
6
+ </div>
7
+
8
+ <div class="bg-white rounded-xl shadow-sm border border-slate-200/60 p-6 hover:shadow-md transition-shadow duration-200">
9
+ <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between mb-4 gap-4">
10
+ <div class="flex items-start gap-4">
11
+ <div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center flex-shrink-0 shadow-sm">
12
+ <span class="text-white font-medium text-lg"><%= @subscription.product_initial %></span>
13
+ </div>
14
+ <div>
15
+ <h3 class="text-lg font-medium text-slate-900 mb-1"><%= @subscription.product_name %></h3>
16
+ <% if @subscription.has_price? %>
17
+ <p class="text-slate-600 text-sm">
18
+ <span class="font-medium text-slate-900"><%= number_to_currency(@subscription.amount, unit: @subscription.currency_symbol) %></span>
19
+ <span class="text-slate-400 mx-1">·</span>
20
+ <span><%= @subscription.currency %></span>
21
+ <span class="text-slate-400 mx-1">·</span>
22
+ <span>Billed per <%= @subscription.interval_label %></span>
23
+ <span class="text-slate-400 mx-1">·</span>
24
+ <span class="text-slate-400">excl. tax</span>
25
+ </p>
26
+ <% else %>
27
+ <p class="text-slate-600 text-sm"><%= @subscription.price_display_text %></p>
28
+ <% end %>
29
+ </div>
30
+ </div>
31
+ <span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 self-start sm:self-auto">
32
+ <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
33
+ <%= @subscription.status_label %>
34
+ </span>
35
+ </div>
36
+
37
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 pt-5 border-t border-slate-100 mt-5">
38
+ <div class="bg-slate-50/50 rounded-lg p-4">
39
+ <p class="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1"><%= @subscription.billing_date_title %></p>
40
+ <div class="flex items-center gap-3">
41
+ <p class="text-sm font-medium text-slate-900"><%= @subscription.billing_date %></p>
42
+ <% if @subscription.subscription.scheduled_for_cancellation? %>
43
+ <%= button_to "Don't cancel",
44
+ paddle_rails.revoke_subscription_cancellation_path,
45
+ method: :post,
46
+ class: "text-xs font-medium text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1 rounded-md transition-all duration-200 active:scale-[0.98]" %>
47
+ <% elsif @subscription.subscription.active? && !@subscription.subscription.canceled? %>
48
+ <%= button_to "Cancel subscription",
49
+ paddle_rails.cancel_subscription_path,
50
+ method: :post,
51
+ data: { turbo_confirm: "Are you sure you want to cancel your subscription?" },
52
+ class: "text-xs font-medium text-red-600 hover:text-red-700 bg-red-50 hover:bg-red-100 px-2.5 py-1 rounded-md transition-all duration-200 active:scale-[0.98]" %>
53
+ <% end %>
54
+ </div>
55
+ </div>
56
+ <div class="bg-slate-50/50 rounded-lg p-4">
57
+ <p class="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Amount</p>
58
+ <p class="text-sm font-medium text-slate-900">
59
+ <%= @subscription.has_price? ? number_to_currency(@subscription.amount, unit: @subscription.currency_symbol) : "N/A" %>
60
+ <span class="text-slate-500 font-normal">per <%= @subscription.interval_label %></span>
61
+ <span class="text-slate-400 font-normal text-xs ml-1">(excl. tax)</span>
62
+ </p>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </section>
@@ -0,0 +1,79 @@
1
+ <!-- Payment History Section -->
2
+ <section class="mb-8 md:mb-12">
3
+ <div class="mb-6">
4
+ <h2 class="text-lg font-medium text-slate-900">Payment history</h2>
5
+ <p class="text-sm text-slate-500 mt-0.5">View and download your past invoices</p>
6
+ </div>
7
+
8
+ <% if @payments.any? %>
9
+ <div class="bg-white rounded-xl shadow-sm border border-slate-200/60 overflow-hidden">
10
+ <div class="overflow-x-auto">
11
+ <table class="w-full min-w-[640px]">
12
+ <thead class="bg-slate-50/80 border-b border-slate-200/60">
13
+ <tr>
14
+ <th class="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Date</th>
15
+ <th class="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Description</th>
16
+ <th class="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Amount</th>
17
+ <th class="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Status</th>
18
+ <th class="px-6 py-4 text-right text-xs font-semibold text-slate-600 uppercase tracking-wider">Invoice</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody class="bg-white divide-y divide-slate-100">
22
+ <% @payments.each do |payment| %>
23
+ <tr class="hover:bg-slate-50/50 transition-colors duration-150">
24
+ <td class="px-6 py-4 whitespace-nowrap">
25
+ <span class="text-sm font-medium text-slate-900"><%= payment.date %></span>
26
+ </td>
27
+ <td class="px-6 py-4 whitespace-nowrap">
28
+ <span class="text-sm text-slate-600"><%= payment.description %></span>
29
+ </td>
30
+ <td class="px-6 py-4 whitespace-nowrap">
31
+ <span class="text-sm font-semibold <%= payment.credit? ? 'text-amber-600' : 'text-slate-900' %>">
32
+ <%= payment.amount %>
33
+ </span>
34
+ </td>
35
+ <td class="px-6 py-4 whitespace-nowrap">
36
+ <span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium <%= payment.status_badge_class %>">
37
+ <span class="w-1.5 h-1.5 rounded-full <%= payment.credit? ? 'bg-amber-500' : (payment.status == 'completed' ? 'bg-emerald-500' : 'bg-slate-400') %>"></span>
38
+ <%= payment.status_label %>
39
+ </span>
40
+ </td>
41
+ <td class="px-6 py-4 whitespace-nowrap text-right">
42
+ <% if payment.has_invoice? %>
43
+ <div class="inline-flex items-center gap-3">
44
+ <a href="<%= view_payment_invoice_path(payment.id) %>" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
45
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
46
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
47
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
48
+ </svg>
49
+ View
50
+ </a>
51
+ <a href="<%= download_payment_invoice_path(payment.id) %>" class="inline-flex items-center gap-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-700 transition-colors">
52
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
54
+ </svg>
55
+ PDF
56
+ </a>
57
+ </div>
58
+ <% else %>
59
+ <span class="text-sm text-slate-400">—</span>
60
+ <% end %>
61
+ </td>
62
+ </tr>
63
+ <% end %>
64
+ </tbody>
65
+ </table>
66
+ </div>
67
+ </div>
68
+ <% else %>
69
+ <div class="bg-white rounded-xl shadow-sm border border-slate-200/60 p-12 text-center">
70
+ <div class="w-14 h-14 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-4">
71
+ <svg class="w-7 h-7 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
72
+ <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" />
73
+ </svg>
74
+ </div>
75
+ <h3 class="text-sm font-medium text-slate-900 mb-1">No payment history</h3>
76
+ <p class="text-sm text-slate-500">Your payment history will appear here once you have completed transactions.</p>
77
+ </div>
78
+ <% end %>
79
+ </section>
@@ -0,0 +1,48 @@
1
+ <!-- Payment Method Section -->
2
+ <section class="mb-8 md:mb-12">
3
+ <div class="mb-6">
4
+ <h2 class="text-lg font-medium text-slate-900">Payment method</h2>
5
+ <p class="text-sm text-slate-500 mt-0.5">Your saved payment details for automatic billing</p>
6
+ </div>
7
+
8
+ <div class="bg-white rounded-xl shadow-sm border border-slate-200/60 p-6 hover:shadow-md transition-shadow duration-200">
9
+ <% if @subscription.has_payment_method? %>
10
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
11
+ <div class="flex items-center gap-4">
12
+ <div class="w-14 h-10 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center">
13
+ <%= payment_method_icon(@subscription.card_brand) %>
14
+ </div>
15
+ <div>
16
+ <div class="flex items-center gap-2">
17
+ <p class="font-medium text-slate-900">
18
+ <%= @subscription.card_brand %> •••• <%= @subscription.card_last4 %>
19
+ </p>
20
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20">
21
+ Default
22
+ </span>
23
+ </div>
24
+ <% if @subscription.card_expiration %>
25
+ <p class="text-sm text-slate-500 mt-0.5">Expires <%= @subscription.card_expiration %></p>
26
+ <% end %>
27
+ </div>
28
+ </div>
29
+ <%= button_to "Update card details",
30
+ paddle_rails.update_payment_method_path,
31
+ method: :post,
32
+ class: "px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-all duration-200 active:scale-[0.98] shadow-sm w-full sm:w-auto" %>
33
+ </div>
34
+ <% else %>
35
+ <div class="flex flex-col items-center text-center py-6">
36
+ <div class="w-14 h-14 rounded-full bg-slate-100 flex items-center justify-center mb-4">
37
+ <svg class="w-7 h-7 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
38
+ <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" />
39
+ </svg>
40
+ </div>
41
+ <h3 class="text-sm font-medium text-slate-900 mb-1">No payment method saved</h3>
42
+ <p class="text-sm text-slate-500 max-w-sm">
43
+ A payment method will be added automatically during your next checkout.
44
+ </p>
45
+ </div>
46
+ <% end %>
47
+ </div>
48
+ </section>
@@ -0,0 +1,47 @@
1
+ <div class="p-4 md:p-8 max-w-6xl mx-auto">
2
+ <%= render "current_subscription" %>
3
+ <%= render "payment_method" %>
4
+ <%= render "change_plan" %>
5
+ <%= render "payment_history" %>
6
+
7
+ <!-- Trust Footer -->
8
+ <footer class="mt-8 pt-8 border-t border-slate-200/60">
9
+ <div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-slate-500">
10
+ <div class="flex items-center gap-6">
11
+ <a href="#" class="hover:text-slate-700 transition-colors">Terms of service</a>
12
+ <a href="#" class="hover:text-slate-700 transition-colors">Privacy policy</a>
13
+ <a href="#" class="hover:text-slate-700 transition-colors">Contact support</a>
14
+ </div>
15
+ <div class="flex items-center gap-4">
16
+ <span class="flex items-center gap-1.5">
17
+ <svg class="w-4 h-4 text-emerald-600" fill="currentColor" viewBox="0 0 20 20">
18
+ <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" />
19
+ </svg>
20
+ PCI DSS compliant
21
+ </span>
22
+ <span class="flex items-center gap-1.5">
23
+ <svg class="w-4 h-4 text-emerald-600" fill="currentColor" viewBox="0 0 20 20">
24
+ <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" />
25
+ </svg>
26
+ Secure payments
27
+ </span>
28
+ </div>
29
+ </div>
30
+ </footer>
31
+ </div>
32
+
33
+ <!-- script loaded here to make sure it's loaded after the page is rendered -->
34
+ <script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
35
+ <script type="text/javascript">
36
+ Paddle.Environment.set("<%= PaddleRails.configuration.environment || 'sandbox' %>");
37
+ Paddle.Initialize({
38
+ token: "<%= PaddleRails.configuration.public_token %>",
39
+ eventCallback: function(data) {
40
+ if (data.name == "checkout.completed") {
41
+ setTimeout(function() {
42
+ window.location.href = "<%= url_for %>";
43
+ }, 2000);
44
+ };
45
+ }
46
+ });
47
+ </script>
@@ -0,0 +1,100 @@
1
+ <div class="min-h-screen flex items-center justify-center p-4 md:p-8">
2
+ <div class="w-full max-w-5xl">
3
+ <!-- Header -->
4
+ <div class="text-center mb-12">
5
+ <h1 class="text-3xl md:text-4xl lg:text-5xl font-semibold tracking-tight text-gray-900 mb-4">
6
+ Choose your plan
7
+ </h1>
8
+ <p class="text-gray-600 text-lg">Select the subscription plan that works best for you</p>
9
+ </div>
10
+
11
+ <!-- Products Grid -->
12
+ <% if @products.any? %>
13
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
14
+ <% @products.each do |product| %>
15
+ <% next unless product.any_prices? %>
16
+
17
+ <div
18
+ class="bg-white border border-gray-200 rounded-2xl p-6 md:p-7 flex flex-col justify-between"
19
+ data-plan-card="true"
20
+ >
21
+ <div class="flex flex-col gap-4">
22
+ <!-- Product header -->
23
+ <div class="flex items-start justify-between">
24
+ <div>
25
+ <h3 class="text-lg font-normal text-gray-900 mb-1"><%= product.name %></h3>
26
+ <p class="text-sm text-gray-500">
27
+ <span class="font-light text-2xl text-gray-900"><%= product.primary_amount %></span>
28
+ <span class="text-base text-gray-900 ml-0.5"><%= product.primary_currency %></span>
29
+ <span class="text-sm text-gray-600 mx-1">/</span>
30
+ <span class="text-sm text-gray-600"><%= product.primary_billing %></span>
31
+ </p>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Placeholder feature list -->
36
+ <ul class="mt-2 space-y-1 text-sm text-gray-600">
37
+ <li>Includes <span class="font-medium">50,000</span> units per month (placeholder)</li>
38
+ <li>Priority processing and email support</li>
39
+ <li>Clean analytics and usage insights</li>
40
+ </ul>
41
+
42
+ <!-- Billing options (monthly / yearly etc.) -->
43
+ <% if product.multiple_prices? %>
44
+ <div class="mt-4">
45
+ <label class="block text-xs font-medium text-gray-500 mb-1">Billing</label>
46
+ <select
47
+ class="block w-full rounded-lg border-gray-300 text-sm text-gray-900 focus:border-purple-500 focus:ring-purple-500"
48
+ data-price-select="true"
49
+ >
50
+ <% product.price_options.each_with_index do |(price_id, label), idx| %>
51
+ <option
52
+ value="<%= price_id %>"
53
+ data-price-label="<%= label %>"
54
+ <%= "selected" if idx.zero? %>
55
+ >
56
+ <%= label %>
57
+ </option>
58
+ <% end %>
59
+ </select>
60
+ </div>
61
+ <% end %>
62
+ </div>
63
+
64
+ <!-- Action button -->
65
+ <div class="mt-6">
66
+ <%= form_with url: onboarding_checkout_path, method: :post, local: true do |f| %>
67
+ <%= f.hidden_field :paddle_price_id, value: product.primary_price_id, data: { price_input: true } %>
68
+ <%= f.submit product.primary_label, class: "w-full px-4 py-2.5 bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-medium rounded-lg hover:from-purple-600 hover:to-pink-600 transition-colors shadow-sm", data: { price_button: true } %>
69
+ <% end %>
70
+ </div>
71
+ </div>
72
+ <% end %>
73
+ </div>
74
+ <% else %>
75
+ <div class="text-center py-12">
76
+ <p class="text-gray-600">No subscription plans available at this time.</p>
77
+ </div>
78
+ <% end %>
79
+ </div>
80
+ </div>
81
+
82
+ <script>
83
+ document.addEventListener("DOMContentLoaded", function() {
84
+ document.querySelectorAll("[data-price-select]").forEach(function(select) {
85
+ select.addEventListener("change", function(event) {
86
+ var option = select.selectedOptions[0];
87
+ if (!option) return;
88
+
89
+ var card = select.closest("[data-plan-card]");
90
+ if (!card) return;
91
+
92
+ var hiddenField = card.querySelector("[data-price-input]");
93
+ var button = card.querySelector("[data-price-button]");
94
+
95
+ if (hiddenField) hiddenField.value = option.value;
96
+ if (button && option.dataset.priceLabel) button.value = option.dataset.priceLabel;
97
+ });
98
+ });
99
+ });
100
+ </script>