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,94 @@
1
+ <div class="max-w-2xl mx-auto p-8 md:p-12">
2
+ <div class="bg-red-50 border border-red-200 rounded-lg p-6 md:p-8">
3
+ <!-- Error Icon and Title -->
4
+ <div class="flex items-start gap-4 mb-6">
5
+ <div class="flex-shrink-0">
6
+ <svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
8
+ </svg>
9
+ </div>
10
+ <div class="flex-1">
11
+ <h1 class="text-xl font-medium text-gray-900 mb-2">PaddleRails Configuration Error</h1>
12
+ <p class="text-gray-600 text-sm leading-relaxed">
13
+ Your PaddleRails integration is not properly configured. Please complete the following steps to set up the gem.
14
+ </p>
15
+ </div>
16
+ </div>
17
+
18
+ <!-- Checklist -->
19
+ <div class="space-y-4">
20
+ <h2 class="text-sm font-medium text-gray-900 mb-3">Configuration Checklist:</h2>
21
+
22
+ <div class="space-y-3">
23
+ <!-- API Keys / Environment Variables -->
24
+ <div class="flex items-start gap-3">
25
+ <div class="flex-shrink-0 mt-0.5">
26
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
28
+ </svg>
29
+ </div>
30
+ <div class="flex-1">
31
+ <h3 class="text-sm font-medium text-gray-900 mb-1">API Keys / Environment Variables</h3>
32
+ <p class="text-sm text-gray-600 mb-2">
33
+ Set the following environment variables in your application:
34
+ </p>
35
+ <ul class="list-disc list-inside text-sm text-gray-600 space-y-1 ml-4">
36
+ <li><code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">PADDLE_API_KEY</code> - Your Paddle API key</li>
37
+ <li><code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">PADDLE_PUBLIC_TOKEN</code> - Your Paddle public token</li>
38
+ <li><code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">PADDLE_ENVIRONMENT</code> - Set to "production" or "sandbox"</li>
39
+ </ul>
40
+ </div>
41
+ </div>
42
+
43
+ <!-- Setup subscription_owner -->
44
+ <div class="flex items-start gap-3">
45
+ <div class="flex-shrink-0 mt-0.5">
46
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
47
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
48
+ </svg>
49
+ </div>
50
+ <div class="flex-1">
51
+ <h3 class="text-sm font-medium text-gray-900 mb-1">Setup subscription_owner</h3>
52
+ <p class="text-sm text-gray-600 mb-2">
53
+ Configure the <code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">subscription_owner_authenticator</code> in your PaddleRails initializer:
54
+ </p>
55
+ <pre class="bg-gray-100 rounded p-3 text-xs font-mono text-gray-800 overflow-x-auto"><code>PaddleRails.configure do |config|
56
+ config.subscription_owner_authenticator do
57
+ current_user || warden.authenticate!(scope: :user)
58
+ end
59
+ end</code></pre>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Add Subscribable concern -->
64
+ <div class="flex items-start gap-3">
65
+ <div class="flex-shrink-0 mt-0.5">
66
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
67
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
68
+ </svg>
69
+ </div>
70
+ <div class="flex-1">
71
+ <h3 class="text-sm font-medium text-gray-900 mb-1">Add Subscribable concern to subscription owner</h3>
72
+ <p class="text-sm text-gray-600 mb-2">
73
+ Include the <code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">PaddleRails::Subscribable</code> concern in your subscription owner model:
74
+ </p>
75
+ <pre class="bg-gray-100 rounded p-3 text-xs font-mono text-gray-800 overflow-x-auto"><code>class User < ApplicationRecord
76
+ include PaddleRails::Subscribable
77
+ end</code></pre>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Documentation Link -->
84
+ <div class="mt-6 pt-6 border-t border-red-200">
85
+ <p class="text-sm text-gray-600">
86
+ For more information, see the
87
+ <a href="https://github.com/kjellberg/paddle_rails" class="text-red-600 hover:text-red-700 font-medium underline" target="_blank" rel="noopener noreferrer">
88
+ PaddleRails documentation
89
+ </a>.
90
+ </p>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ PaddleRails::Engine.routes.draw do
2
+ root "dashboard#show"
3
+ get "onboarding", to: "onboarding#show", as: :onboarding
4
+ post "onboarding/checkout", to: "onboarding#create_checkout", as: :onboarding_checkout
5
+ get "checkout", to: "checkout#show", as: :checkout
6
+ get "checkout/check_status/:transaction_id", to: "checkout#check_status", as: :check_transaction_status
7
+ post "checkout/update_payment_method", to: "checkout#update_payment_method", as: :update_payment_method
8
+ post "subscriptions/revoke_cancellation", to: "subscriptions#revoke_cancellation", as: :revoke_subscription_cancellation
9
+ post "subscriptions/cancel", to: "subscriptions#cancel", as: :cancel_subscription
10
+ post "subscriptions/change_plan", to: "subscriptions#change_plan", as: :change_subscription_plan
11
+ get "payments/:id/invoice", to: "payments#view_invoice", as: :view_payment_invoice
12
+ get "payments/:id/download", to: "payments#download_invoice", as: :download_payment_invoice
13
+ end
@@ -0,0 +1,18 @@
1
+ class CreatePaddleRailsSubscriptionPlans < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :paddle_rails_subscription_plans do |t|
4
+ t.string :paddle_product_id, null: false
5
+ t.string :name
6
+ t.text :description
7
+ t.string :status
8
+ t.string :type
9
+ t.string :tax_category
10
+ t.string :image_url
11
+ t.json :custom_data
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :paddle_rails_subscription_plans, :paddle_product_id, unique: true
17
+ end
18
+ end
@@ -0,0 +1,26 @@
1
+ class CreatePaddleRailsSubscriptionPrices < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :paddle_rails_subscription_prices do |t|
4
+ t.references :subscription_plan, null: false, foreign_key: { to_table: :paddle_rails_subscription_plans }
5
+ t.string :paddle_price_id, null: false
6
+ t.string :currency
7
+ t.integer :unit_price
8
+ t.string :billing_interval
9
+ t.integer :billing_interval_count
10
+ t.integer :trial_days
11
+ t.string :type
12
+ t.string :name
13
+ t.text :description
14
+ t.string :status
15
+ t.string :tax_mode
16
+ t.integer :quantity_minimum
17
+ t.integer :quantity_maximum
18
+ t.json :trial_period
19
+ t.json :custom_data
20
+
21
+ t.timestamps
22
+ end
23
+
24
+ add_index :paddle_rails_subscription_prices, :paddle_price_id, unique: true
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ class CreatePaddleRailsWebhookEvents < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :paddle_rails_webhook_events do |t|
4
+ t.string :external_id, null: false
5
+ t.string :event_type, null: false
6
+ t.json :payload, null: false
7
+ t.string :status, null: false, default: "pending"
8
+ t.text :processing_errors
9
+ t.datetime :processed_at
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :paddle_rails_webhook_events, :external_id, unique: true
15
+ add_index :paddle_rails_webhook_events, :event_type
16
+ add_index :paddle_rails_webhook_events, :status
17
+ end
18
+ end
19
+
@@ -0,0 +1,21 @@
1
+ class CreatePaddleRailsSubscriptions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :paddle_rails_subscriptions do |t|
4
+ t.references :owner, polymorphic: true, null: false, index: true
5
+ t.string :paddle_subscription_id, null: false
6
+ t.string :paddle_price_id
7
+ t.string :status
8
+ t.datetime :current_period_end_at
9
+ t.datetime :trial_ends_at
10
+ t.json :raw_payload
11
+ t.references :subscription_price, foreign_key: { to_table: :paddle_rails_subscription_prices }, index: true
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :paddle_rails_subscriptions, :paddle_subscription_id, unique: true
17
+ add_index :paddle_rails_subscriptions, :paddle_price_id
18
+ add_index :paddle_rails_subscriptions, :status
19
+ end
20
+ end
21
+
@@ -0,0 +1,16 @@
1
+ class CreatePaddleRailsSubscriptionItems < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :paddle_rails_subscription_items do |t|
4
+ t.references :subscription, foreign_key: { to_table: :paddle_rails_subscriptions }, null: false, index: true
5
+ t.references :subscription_price, foreign_key: { to_table: :paddle_rails_subscription_prices }, null: false, index: true
6
+ t.integer :quantity, default: 1
7
+ t.string :status
8
+ t.boolean :recurring, default: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :paddle_rails_subscription_items, [:subscription_id, :subscription_price_id], unique: true, name: "index_subscription_items_on_subscription_and_price"
14
+ end
15
+ end
16
+
@@ -0,0 +1,7 @@
1
+ class RemovePaddlePriceIdFromSubscriptions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_index :paddle_rails_subscriptions, :paddle_price_id, if_exists: true
4
+ remove_column :paddle_rails_subscriptions, :paddle_price_id, :string, if_exists: true
5
+ end
6
+ end
7
+
@@ -0,0 +1,6 @@
1
+ class RenameSubscriptionPlansToProducts < ActiveRecord::Migration[8.1]
2
+ def change
3
+ rename_table :paddle_rails_subscription_plans, :paddle_rails_subscription_products
4
+ end
5
+ end
6
+
@@ -0,0 +1,13 @@
1
+ class RenameSubscriptionPlanIdToSubscriptionProductId < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Remove old foreign key
4
+ remove_foreign_key :paddle_rails_subscription_prices, :paddle_rails_subscription_plans if foreign_key_exists?(:paddle_rails_subscription_prices, :paddle_rails_subscription_plans)
5
+
6
+ # Rename the column
7
+ rename_column :paddle_rails_subscription_prices, :subscription_plan_id, :subscription_product_id
8
+
9
+ # Add new foreign key
10
+ add_foreign_key :paddle_rails_subscription_prices, :paddle_rails_subscription_products, column: :subscription_product_id
11
+ end
12
+ end
13
+
@@ -0,0 +1,8 @@
1
+ class RemoveSubscriptionPriceIdFromSubscriptions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_foreign_key :paddle_rails_subscriptions, :paddle_rails_subscription_prices, if_exists: true
4
+ remove_index :paddle_rails_subscriptions, :subscription_price_id, if_exists: true
5
+ remove_column :paddle_rails_subscriptions, :subscription_price_id, :integer, if_exists: true
6
+ end
7
+ end
8
+
@@ -0,0 +1,8 @@
1
+ class AddSubscriptionProductIdToSubscriptionItems < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_reference :paddle_rails_subscription_items, :subscription_product,
4
+ foreign_key: { to_table: :paddle_rails_subscription_products },
5
+ index: true
6
+ end
7
+ end
8
+
@@ -0,0 +1,6 @@
1
+ class RemovePaddleItemIdFromSubscriptionItems < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_column :paddle_rails_subscription_items, :paddle_item_id, :string, if_exists: true
4
+ end
5
+ end
6
+
@@ -0,0 +1,6 @@
1
+ class RenameSubscriptionProductsToProducts < ActiveRecord::Migration[8.1]
2
+ def change
3
+ rename_table :paddle_rails_subscription_products, :paddle_rails_products
4
+ end
5
+ end
6
+
@@ -0,0 +1,6 @@
1
+ class RenameSubscriptionPricesToPrices < ActiveRecord::Migration[8.1]
2
+ def change
3
+ rename_table :paddle_rails_subscription_prices, :paddle_rails_prices
4
+ end
5
+ end
6
+
@@ -0,0 +1,13 @@
1
+ class RenameSubscriptionProductIdToProductIdInPrices < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Remove old foreign key
4
+ remove_foreign_key :paddle_rails_prices, :paddle_rails_subscription_products, if_exists: true
5
+
6
+ # Rename the column
7
+ rename_column :paddle_rails_prices, :subscription_product_id, :product_id
8
+
9
+ # Add new foreign key
10
+ add_foreign_key :paddle_rails_prices, :paddle_rails_products, column: :product_id
11
+ end
12
+ end
13
+
@@ -0,0 +1,20 @@
1
+ class RenameFksInSubscriptionItems < ActiveRecord::Migration[8.1]
2
+ def change
3
+ # Remove old foreign keys
4
+ remove_foreign_key :paddle_rails_subscription_items, :paddle_rails_subscription_prices, if_exists: true
5
+ remove_foreign_key :paddle_rails_subscription_items, :paddle_rails_subscription_products, if_exists: true
6
+
7
+ # Rename the columns
8
+ rename_column :paddle_rails_subscription_items, :subscription_price_id, :price_id
9
+ rename_column :paddle_rails_subscription_items, :subscription_product_id, :product_id
10
+
11
+ # Update the unique index
12
+ remove_index :paddle_rails_subscription_items, name: "index_subscription_items_on_subscription_and_price", if_exists: true
13
+ add_index :paddle_rails_subscription_items, [:subscription_id, :price_id], unique: true, name: "index_subscription_items_on_subscription_and_price"
14
+
15
+ # Add new foreign keys
16
+ add_foreign_key :paddle_rails_subscription_items, :paddle_rails_prices, column: :price_id
17
+ add_foreign_key :paddle_rails_subscription_items, :paddle_rails_products, column: :product_id
18
+ end
19
+ end
20
+
@@ -0,0 +1,6 @@
1
+ class AddScheduledCancelationAtToSubscriptions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :paddle_rails_subscriptions, :scheduled_cancelation_at, :datetime
4
+ end
5
+ end
6
+
@@ -0,0 +1,10 @@
1
+ class AddPaymentMethodToSubscriptions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :paddle_rails_subscriptions, :payment_method_id, :string
4
+ add_column :paddle_rails_subscriptions, :payment_method_type, :string
5
+ add_column :paddle_rails_subscriptions, :payment_method_details, :json
6
+
7
+ add_index :paddle_rails_subscriptions, :payment_method_id
8
+ end
9
+ end
10
+
@@ -0,0 +1,24 @@
1
+ class CreatePaddleRailsPayments < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :paddle_rails_payments do |t|
4
+ t.references :subscription, null: false, foreign_key: { to_table: :paddle_rails_subscriptions }
5
+ t.references :owner, polymorphic: true, null: false
6
+ t.string :paddle_transaction_id, null: false
7
+ t.string :invoice_id
8
+ t.string :invoice_number
9
+ t.string :status, null: false
10
+ t.string :origin # subscription_recurring, subscription_update, etc.
11
+ t.integer :total # total in cents (incl. tax)
12
+ t.integer :tax
13
+ t.integer :subtotal
14
+ t.string :currency
15
+ t.datetime :billed_at
16
+ t.json :details # store line_items and other details
17
+ t.json :raw_payload
18
+ t.timestamps
19
+ end
20
+
21
+ add_index :paddle_rails_payments, :paddle_transaction_id, unique: true
22
+ add_index :paddle_rails_payments, [:owner_type, :owner_id]
23
+ end
24
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaddleRails
4
+ # Service object for creating Paddle transactions (checkouts).
5
+ #
6
+ # Wraps {Paddle::Transaction.create} and is responsible for:
7
+ # - Building the items array from a Paddle price ID.
8
+ # - Merging the owner's **signed** GlobalID into the `custom_data` hash under
9
+ # the \"owner_sgid\" key, so the owner reference is tamper‑evident.
10
+ # - Returning the resulting {Paddle::Transaction} instance.
11
+ #
12
+ # @example Basic usage
13
+ # checkout = PaddleRails::Checkout.create(
14
+ # owner: user,
15
+ # paddle_price_id: "pri_123"
16
+ # )
17
+ #
18
+ # redirect_to checkout.details.checkout.url, allow_other_host: true
19
+ #
20
+ # @example With additional custom data
21
+ # checkout = PaddleRails::Checkout.create(
22
+ # owner: user,
23
+ # paddle_price_id: "pri_123",
24
+ # custom_data: { foo: "bar" }
25
+ # )
26
+ #
27
+ # # custom_data will be merged with a signed owner SGID:
28
+ # # { \"owner_sgid\" => user.to_sgid_param(for: \"paddle_rails_owner\"), \"foo\" => \"bar\" }
29
+ class Checkout
30
+ attr_reader :owner, :paddle_price_id, :custom_data, :checkout_url
31
+
32
+ # Create a new checkout (transaction) for a given owner and price.
33
+ #
34
+ # @param owner [Object] the subscription owner (e.g. User) – must support `to_gid_param`
35
+ # @param paddle_price_id [String] the Paddle Price ID used for the transaction
36
+ # @param custom_data [Hash] optional extra metadata to merge into `custom_data`
37
+ # @param checkout_url [String, nil] optional URL that Paddle should use for the checkout
38
+ # @return [Paddle::Transaction] the created transaction
39
+ def self.create(owner:, paddle_price_id:, custom_data: {}, checkout_url: nil)
40
+ new(
41
+ owner: owner,
42
+ paddle_price_id: paddle_price_id,
43
+ custom_data: custom_data,
44
+ checkout_url: checkout_url
45
+ ).create
46
+ end
47
+
48
+ # Convenience helper that creates a checkout and returns only the hosted URL.
49
+ #
50
+ # @param owner [Object] the subscription owner (e.g. User)
51
+ # @param paddle_price_id [String] the Paddle Price ID used for the transaction
52
+ # @param custom_data [Hash] optional extra metadata to merge into `custom_data`
53
+ # @param checkout_url [String, nil] optional URL that Paddle should use for the checkout
54
+ # @return [String, nil] the checkout URL, if present
55
+ def self.url_for(owner:, paddle_price_id:, custom_data: {}, checkout_url: nil)
56
+ transaction = create(
57
+ owner: owner,
58
+ paddle_price_id: paddle_price_id,
59
+ custom_data: custom_data,
60
+ checkout_url: checkout_url
61
+ )
62
+
63
+ extract_url(transaction)
64
+ end
65
+
66
+ # @param owner [Object]
67
+ # @param paddle_price_id [String]
68
+ # @param custom_data [Hash]
69
+ # @param checkout_url [String, nil]
70
+ def initialize(owner:, paddle_price_id:, custom_data: {}, checkout_url: nil)
71
+ @owner = owner
72
+ @paddle_price_id = paddle_price_id
73
+ @custom_data = custom_data || {}
74
+ @checkout_url = checkout_url
75
+ end
76
+
77
+ # Perform the transaction creation against the Paddle API.
78
+ #
79
+ # @return [Paddle::Transaction]
80
+ def create
81
+ attrs = {
82
+ items: [{ price_id: paddle_price_id, quantity: 1 }],
83
+ custom_data: merged_custom_data
84
+ }
85
+
86
+ if checkout_url
87
+ attrs[:checkout] = { url: normalize_checkout_url_for_paddle(checkout_url) }
88
+ end
89
+
90
+ Paddle::Transaction.create(**attrs)
91
+ end
92
+
93
+ private
94
+
95
+ def merged_custom_data
96
+ base = { "owner_sgid" => owner_sgid }
97
+ # Stringify keys to keep custom_data consistent with Paddle expectations
98
+ base.merge(stringified_custom_data)
99
+ end
100
+
101
+ # Build a signed GlobalID string for the owner that can be verified later.
102
+ #
103
+ # NOTE: Webhook processing is not implemented yet, but when it is, you
104
+ # should resolve this using:
105
+ #
106
+ # GlobalID::Locator.locate_signed(owner_sgid, for: \"paddle_rails_owner\")
107
+ #
108
+ # @return [String]
109
+ def owner_sgid
110
+ if owner.respond_to?(:to_sgid_param)
111
+ owner.to_sgid_param(for: "paddle_rails_owner")
112
+ elsif defined?(GlobalID::SignedGlobalID)
113
+ GlobalID::SignedGlobalID.create(owner, for: "paddle_rails_owner").to_s
114
+ else
115
+ owner.to_s
116
+ end
117
+ end
118
+
119
+ def stringified_custom_data
120
+ custom_data.to_h.transform_keys(&:to_s)
121
+ end
122
+
123
+ # In development, convert all http:// URLs to https:// for
124
+ # checkout URLs to avoid Paddle's domain approval error.
125
+ #
126
+ # @param url [String, nil]
127
+ # @return [String, nil]
128
+ def normalize_checkout_url_for_paddle(url)
129
+ return url unless url
130
+ return url unless defined?(Rails) && Rails.env.development?
131
+
132
+ url.sub(/\Ahttp:\/\//, "https://")
133
+ end
134
+
135
+ # Extract the hosted checkout URL from a Paddle::Transaction.
136
+ #
137
+ # The Paddle gem currently exposes this via an internal OpenStruct
138
+ # where the raw attributes live in `checkout.table`.
139
+ #
140
+ # @param transaction [Paddle::Transaction, nil]
141
+ # @return [String, nil]
142
+ def self.extract_url(transaction)
143
+ return nil unless transaction
144
+
145
+ url = nil
146
+
147
+ # Preferred: transaction.checkout.table[:url]
148
+ if transaction.respond_to?(:checkout) && transaction.checkout
149
+ checkout = transaction.checkout
150
+ if checkout.respond_to?(:table)
151
+ url = checkout.table[:url] || checkout.table["url"]
152
+ end
153
+
154
+ url ||= checkout.url if checkout.respond_to?(:url)
155
+ end
156
+
157
+ # Fallback: nested details.checkout.url (for other Paddle shapes)
158
+ if url.nil? && transaction.respond_to?(:details) && transaction.details&.respond_to?(:checkout)
159
+ nested = transaction.details.checkout
160
+ url = nested.url if nested.respond_to?(:url)
161
+ end
162
+
163
+ rewrite_localhost_url(url)
164
+ end
165
+
166
+ # In development, Paddle may return https URLs which
167
+ # Rails typically serves over plain HTTP. Normalize those so we
168
+ # don't hit mixed-scheme issues in local setups.
169
+ #
170
+ # @param url [String, nil]
171
+ # @return [String, nil]
172
+ def self.rewrite_localhost_url(url)
173
+ return url unless url
174
+ return url unless defined?(Rails) && Rails.env.development?
175
+
176
+ url.sub(/\Ahttps:\/\//, "http://")
177
+ end
178
+ end
179
+ end
180
+
181
+