rsb-entitlements 0.9.1

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +73 -0
  4. data/Rakefile +25 -0
  5. data/app/controllers/rsb/entitlements/admin/payment_requests_controller.rb +112 -0
  6. data/app/controllers/rsb/entitlements/admin/plans_controller.rb +91 -0
  7. data/app/controllers/rsb/entitlements/admin/usage_counters_controller.rb +69 -0
  8. data/app/jobs/rsb/entitlements/application_job.rb +8 -0
  9. data/app/jobs/rsb/entitlements/entitlement_expiration_job.rb +15 -0
  10. data/app/jobs/rsb/entitlements/payment_request_expiration_job.rb +31 -0
  11. data/app/models/concerns/rsb/entitlements/entitleable.rb +210 -0
  12. data/app/models/rsb/entitlements/application_record.rb +10 -0
  13. data/app/models/rsb/entitlements/entitlement.rb +68 -0
  14. data/app/models/rsb/entitlements/payment_request.rb +70 -0
  15. data/app/models/rsb/entitlements/plan.rb +83 -0
  16. data/app/models/rsb/entitlements/usage_counter.rb +64 -0
  17. data/app/services/rsb/entitlements/usage_counter_service.rb +94 -0
  18. data/app/views/rsb/entitlements/admin/payment_requests/index.html.erb +98 -0
  19. data/app/views/rsb/entitlements/admin/payment_requests/show.html.erb +137 -0
  20. data/app/views/rsb/entitlements/admin/plans/_form.html.erb +202 -0
  21. data/app/views/rsb/entitlements/admin/plans/edit.html.erb +9 -0
  22. data/app/views/rsb/entitlements/admin/plans/index.html.erb +74 -0
  23. data/app/views/rsb/entitlements/admin/plans/new.html.erb +9 -0
  24. data/app/views/rsb/entitlements/admin/plans/show.html.erb +94 -0
  25. data/app/views/rsb/entitlements/admin/usage_counters/index.html.erb +110 -0
  26. data/app/views/rsb/entitlements/admin/usage_counters/trend.html.erb +57 -0
  27. data/config/locales/admin.en.yml +25 -0
  28. data/db/migrate/20260208200001_create_rsb_entitlements_plans.rb +21 -0
  29. data/db/migrate/20260208200002_create_rsb_entitlements_entitlements.rb +23 -0
  30. data/db/migrate/20260208200003_create_rsb_entitlements_usage_counters.rb +21 -0
  31. data/db/migrate/20260208200004_create_rsb_entitlements_payment_requests.rb +37 -0
  32. data/db/migrate/20260213000001_rework_usage_counters_to_ledger.rb +81 -0
  33. data/lib/generators/rsb/entitlements/install/install_generator.rb +26 -0
  34. data/lib/rsb/entitlements/configuration.rb +19 -0
  35. data/lib/rsb/entitlements/engine.rb +134 -0
  36. data/lib/rsb/entitlements/payment_provider/base.rb +148 -0
  37. data/lib/rsb/entitlements/payment_provider/wire.rb +188 -0
  38. data/lib/rsb/entitlements/period_key_calculator.rb +57 -0
  39. data/lib/rsb/entitlements/provider_definition.rb +43 -0
  40. data/lib/rsb/entitlements/provider_registry.rb +145 -0
  41. data/lib/rsb/entitlements/settings_schema.rb +47 -0
  42. data/lib/rsb/entitlements/test_helper.rb +114 -0
  43. data/lib/rsb/entitlements/version.rb +9 -0
  44. data/lib/rsb/entitlements.rb +39 -0
  45. metadata +116 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class Entitlement < ApplicationRecord
6
+ STATUSES = %w[pending active expired revoked].freeze
7
+ REVOKE_REASONS = %w[refund admin chargeback non_renewal upgrade].freeze
8
+
9
+ belongs_to :entitleable, polymorphic: true
10
+ belongs_to :plan
11
+
12
+ validates :status, presence: true, inclusion: { in: STATUSES }
13
+ validates :provider, presence: true,
14
+ inclusion: {
15
+ in: ->(_) { RSB::Entitlements.providers.keys.map(&:to_s) },
16
+ message: 'is not a registered provider'
17
+ }
18
+ validates :revoke_reason, inclusion: { in: REVOKE_REASONS }, allow_nil: true
19
+
20
+ scope :active, -> { where(status: 'active') }
21
+ scope :current, -> { where(status: %w[pending active]) }
22
+
23
+ after_commit :fire_changed_callback, if: :saved_change_to_status?
24
+ after_commit :create_usage_counters, on: %i[create update], if: :active?
25
+
26
+ def activate!
27
+ update!(status: 'active', activated_at: Time.current)
28
+ end
29
+
30
+ def expire!
31
+ update!(status: 'expired')
32
+ end
33
+
34
+ def revoke!(reason:)
35
+ update!(status: 'revoked', revoked_at: Time.current, revoke_reason: reason)
36
+ end
37
+
38
+ def active?
39
+ status == 'active'
40
+ end
41
+
42
+ def expired?
43
+ status == 'expired'
44
+ end
45
+
46
+ def revoked?
47
+ status == 'revoked'
48
+ end
49
+
50
+ def pending?
51
+ status == 'pending'
52
+ end
53
+
54
+ private
55
+
56
+ def fire_changed_callback
57
+ callback = RSB::Entitlements.configuration.after_entitlement_changed
58
+ callback&.call(self)
59
+ end
60
+
61
+ def create_usage_counters
62
+ return unless saved_change_to_status? && status == 'active'
63
+
64
+ UsageCounterService.new.create_counters_for(self)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class PaymentRequest < ApplicationRecord
6
+ STATUSES = %w[pending processing approved rejected expired refunded].freeze
7
+ ACTIONABLE_STATUSES = %w[pending processing].freeze
8
+
9
+ belongs_to :requestable, polymorphic: true
10
+ belongs_to :plan
11
+ belongs_to :entitlement, optional: true
12
+
13
+ validates :provider_key, presence: true,
14
+ inclusion: {
15
+ in: ->(_) { RSB::Entitlements.providers.keys.map(&:to_s) },
16
+ message: 'is not a registered provider'
17
+ }
18
+ validates :status, presence: true, inclusion: { in: STATUSES }
19
+ validates :amount_cents, numericality: { greater_than_or_equal_to: 0 }
20
+ validates :currency, presence: true
21
+
22
+ scope :actionable, -> { where(status: ACTIONABLE_STATUSES) }
23
+ scope :for_provider, ->(key) { where(provider_key: key.to_s) }
24
+
25
+ after_commit :fire_changed_callback, if: :saved_change_to_status?
26
+
27
+ # @return [Boolean] true if status is "pending"
28
+ def pending?
29
+ status == 'pending'
30
+ end
31
+
32
+ # @return [Boolean] true if status is "processing"
33
+ def processing?
34
+ status == 'processing'
35
+ end
36
+
37
+ # @return [Boolean] true if status is "approved"
38
+ def approved?
39
+ status == 'approved'
40
+ end
41
+
42
+ # @return [Boolean] true if status is "rejected"
43
+ def rejected?
44
+ status == 'rejected'
45
+ end
46
+
47
+ # @return [Boolean] true if status is "expired"
48
+ def expired?
49
+ status == 'expired'
50
+ end
51
+
52
+ # @return [Boolean] true if status is "refunded"
53
+ def refunded?
54
+ status == 'refunded'
55
+ end
56
+
57
+ # @return [Boolean] true if status is "pending" or "processing"
58
+ def actionable?
59
+ ACTIONABLE_STATUSES.include?(status)
60
+ end
61
+
62
+ private
63
+
64
+ def fire_changed_callback
65
+ callback = RSB::Entitlements.configuration.after_payment_request_changed
66
+ callback&.call(self)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class Plan < ApplicationRecord
6
+ INTERVALS = %w[monthly yearly lifetime one_time].freeze
7
+
8
+ has_many :entitlements, dependent: :restrict_with_error
9
+ has_many :usage_counters, dependent: :restrict_with_error
10
+
11
+ validates :name, presence: true
12
+ validates :slug, presence: true,
13
+ uniqueness: true,
14
+ format: { with: /\A[a-z0-9_-]+\z/ }
15
+ validates :interval, presence: true, inclusion: { in: INTERVALS }
16
+ validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
17
+ validates :currency, presence: true
18
+
19
+ scope :active, -> { where(active: true) }
20
+
21
+ def free?
22
+ price_cents.zero?
23
+ end
24
+
25
+ def feature?(key)
26
+ features[key.to_s] == true
27
+ end
28
+
29
+ # Returns the integer limit value for a metric from the nested limits config.
30
+ #
31
+ # @param key [String, Symbol] The metric key (e.g., "api_calls")
32
+ # @return [Integer, nil] The limit value, or nil if undefined or unlimited
33
+ #
34
+ # @example
35
+ # plan.limit_for("api_calls") # => 1000
36
+ # plan.limit_for(:projects) # => 10
37
+ # plan.limit_for("undefined") # => nil
38
+ def limit_for(key)
39
+ config = limits[key.to_s]
40
+ return nil unless config.is_a?(Hash)
41
+
42
+ config['limit']
43
+ end
44
+
45
+ # Returns the period type for a metric from the nested limits config.
46
+ #
47
+ # @param key [String, Symbol] The metric key (e.g., "api_calls")
48
+ # @return [String, nil] The period type ("daily", "weekly", "monthly"), or nil for cumulative
49
+ #
50
+ # @example
51
+ # plan.period_for("api_calls") # => "daily"
52
+ # plan.period_for(:projects) # => nil (cumulative)
53
+ # plan.period_for("undefined") # => nil
54
+ def period_for(key)
55
+ config = limits[key.to_s]
56
+ return nil unless config.is_a?(Hash)
57
+
58
+ config['period']
59
+ end
60
+
61
+ # Returns the full limit configuration hash for a metric.
62
+ #
63
+ # @param key [String, Symbol] The metric key (e.g., "api_calls")
64
+ # @return [Hash, nil] The full config hash with "limit" and "period" keys, or nil if undefined
65
+ #
66
+ # @example
67
+ # plan.limit_config_for("api_calls")
68
+ # # => { "limit" => 1000, "period" => "daily" }
69
+ #
70
+ # plan.limit_config_for("projects")
71
+ # # => { "limit" => 10, "period" => nil }
72
+ #
73
+ # plan.limit_config_for("undefined")
74
+ # # => nil
75
+ def limit_config_for(key)
76
+ config = limits[key.to_s]
77
+ return nil unless config.is_a?(Hash)
78
+
79
+ config
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class UsageCounter < ApplicationRecord
6
+ belongs_to :countable, polymorphic: true
7
+ belongs_to :plan, class_name: 'RSB::Entitlements::Plan'
8
+
9
+ validates :metric, presence: true,
10
+ uniqueness: { scope: %i[countable_type countable_id period_key plan_id] }
11
+ validates :period_key, presence: true
12
+ validates :current_value, numericality: { greater_than_or_equal_to: 0 }
13
+
14
+ scope :for_metric, ->(metric) { where(metric: metric.to_s) }
15
+ scope :for_period, ->(period_key) { where(period_key: period_key.to_s) }
16
+ scope :for_plan, ->(plan) { where(plan_id: plan.id) }
17
+ scope :cumulative, -> { where(period_key: PeriodKeyCalculator::CUMULATIVE_KEY) }
18
+ scope :recent, ->(n) { order(period_key: :desc).limit(n) }
19
+
20
+ # Atomically increments the counter's current_value.
21
+ #
22
+ # Uses SQL UPDATE to ensure atomicity under concurrent access.
23
+ # Fires the `after_usage_limit_reached` callback if the counter reaches its limit.
24
+ #
25
+ # @param amount [Integer] the amount to increment by (default: 1)
26
+ # @return [Integer] the new current_value after increment
27
+ def increment!(amount = 1)
28
+ self.class.where(id: id).update_all(
29
+ ['current_value = current_value + ?', amount]
30
+ )
31
+ reload
32
+ check_limit_reached
33
+ current_value
34
+ end
35
+
36
+ # Returns whether the counter has reached its limit.
37
+ #
38
+ # @return [Boolean] true if current_value >= limit, false if no limit set
39
+ def at_limit?
40
+ return false if limit.nil?
41
+
42
+ current_value >= limit
43
+ end
44
+
45
+ # Returns the remaining quota before the limit is reached.
46
+ #
47
+ # @return [Integer, nil] remaining count, or nil if no limit (unlimited)
48
+ def remaining
49
+ return nil if limit.nil?
50
+
51
+ [limit - current_value, 0].max
52
+ end
53
+
54
+ private
55
+
56
+ def check_limit_reached
57
+ return unless at_limit?
58
+
59
+ callback = RSB::Entitlements.configuration.after_usage_limit_reached
60
+ callback&.call(self)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Entitlements
5
+ class UsageCounterService
6
+ # Creates usage counters for a newly activated entitlement.
7
+ #
8
+ # For each metric in the plan's limits, creates a counter with the correct
9
+ # period_key and limit. Uses find_or_create_by! to avoid duplicates.
10
+ #
11
+ # @param entitlement [RSB::Entitlements::Entitlement] the activated entitlement
12
+ # @return [void]
13
+ def create_counters_for(entitlement)
14
+ return unless entitlement.active?
15
+ return unless RSB::Settings.get('entitlements.auto_create_counters')
16
+
17
+ plan = entitlement.plan
18
+ return if plan.limits.blank?
19
+
20
+ plan.limits.each do |metric, config|
21
+ next unless config.is_a?(Hash)
22
+
23
+ period = config['period']
24
+ limit_value = config['limit']
25
+ period_key = PeriodKeyCalculator.current_key(period)
26
+
27
+ entitlement.entitleable.usage_counters.find_or_create_by!(
28
+ metric: metric.to_s,
29
+ period_key: period_key,
30
+ plan_id: plan.id
31
+ ) do |counter|
32
+ counter.limit = limit_value
33
+ end
34
+ end
35
+ end
36
+
37
+ # Handles usage counter transitions when an entitleable changes plans.
38
+ #
39
+ # For each metric in the new plan:
40
+ # - If the period type changed: creates a fresh counter (value: 0)
41
+ # - If the period type is the same:
42
+ # - "continue" mode: carries over current_value from old counter
43
+ # - "reset" mode: creates counter with value: 0
44
+ # - New metrics get a fresh counter
45
+ # - Removed metrics: old counters are left as historical records
46
+ #
47
+ # @param entitleable [Object] the entitleable record (e.g., Organization)
48
+ # @param old_plan [RSB::Entitlements::Plan] the previous plan
49
+ # @param new_plan [RSB::Entitlements::Plan] the new plan
50
+ # @return [void]
51
+ def handle_plan_change(entitleable, old_plan:, new_plan:)
52
+ mode = begin
53
+ RSB::Settings.get('entitlements.on_plan_change_usage')
54
+ rescue StandardError
55
+ 'continue'
56
+ end
57
+
58
+ source = entitleable.respond_to?(:entitlement_source) ? entitleable.entitlement_source : entitleable
59
+
60
+ new_plan.limits.each do |metric, new_config|
61
+ next unless new_config.is_a?(Hash)
62
+
63
+ new_period = new_config['period']
64
+ new_limit = new_config['limit']
65
+ new_period_key = PeriodKeyCalculator.current_key(new_period)
66
+
67
+ old_config = old_plan.limit_config_for(metric)
68
+ old_period = old_config&.dig('period')
69
+
70
+ # Determine carry-over value
71
+ carry_over = 0
72
+ if old_config && old_period == new_period && mode == 'continue'
73
+ old_period_key = PeriodKeyCalculator.current_key(old_period)
74
+ old_counter = source.usage_counters
75
+ .for_metric(metric)
76
+ .for_period(old_period_key)
77
+ .for_plan(old_plan)
78
+ .last
79
+ carry_over = old_counter&.current_value || 0
80
+ end
81
+
82
+ counter = source.usage_counters.find_or_initialize_by(
83
+ metric: metric.to_s,
84
+ period_key: new_period_key,
85
+ plan_id: new_plan.id
86
+ )
87
+ counter.current_value = carry_over
88
+ counter.limit = new_limit
89
+ counter.save!
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,98 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">Payment Requests</h1>
3
+ </div>
4
+
5
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg p-4 mb-4">
6
+ <form method="get" action="/admin/payment_requests" class="flex flex-wrap items-end gap-4">
7
+ <div>
8
+ <label class="block text-xs font-medium text-rsb-muted mb-1">Status</label>
9
+ <%= select_tag :status,
10
+ options_for_select(
11
+ [["All", ""]] + RSB::Entitlements::PaymentRequest::STATUSES.map { |s| [s.titleize, s] },
12
+ params[:status]
13
+ ),
14
+ class: "px-3 py-1.5 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
15
+ </div>
16
+ <div>
17
+ <label class="block text-xs font-medium text-rsb-muted mb-1">Provider</label>
18
+ <%= select_tag :provider_key,
19
+ options_for_select(
20
+ [["All", ""]] + RSB::Entitlements.providers.all.map { |d| [d.label, d.key.to_s] },
21
+ params[:provider_key]
22
+ ),
23
+ class: "px-3 py-1.5 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
24
+ </div>
25
+ <div class="flex gap-2">
26
+ <button type="submit"
27
+ class="px-4 py-1.5 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
28
+ Filter
29
+ </button>
30
+ <a href="/admin/payment_requests"
31
+ class="px-4 py-1.5 border border-rsb-border text-rsb-muted rounded-rsb text-sm hover:bg-rsb-bg">
32
+ Clear
33
+ </a>
34
+ </div>
35
+ </form>
36
+ </div>
37
+
38
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm overflow-hidden">
39
+ <% if @payment_requests.any? %>
40
+ <div class="overflow-x-auto">
41
+ <table class="w-full">
42
+ <thead>
43
+ <tr>
44
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">ID</th>
45
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Type</th>
46
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Owner ID</th>
47
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Plan</th>
48
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Provider</th>
49
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Status</th>
50
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Amount</th>
51
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Currency</th>
52
+ <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border">Created</th>
53
+ <th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"></th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ <% @payment_requests.each do |request| %>
58
+ <tr class="hover:bg-rsb-bg border-b border-rsb-border last:border-b-0">
59
+ <td class="px-4 py-3 text-sm">
60
+ <a href="/admin/payment_requests/<%= request.id %>" class="text-rsb-primary hover:underline"><%= request.id %></a>
61
+ </td>
62
+ <td class="px-4 py-3 text-sm text-rsb-muted"><%= request.requestable_type.demodulize %></td>
63
+ <td class="px-4 py-3 text-sm"><%= request.requestable_id %></td>
64
+ <td class="px-4 py-3 text-sm"><%= request.plan&.name %></td>
65
+ <td class="px-4 py-3 text-sm"><%= rsb_admin_badge(request.provider_key) %></td>
66
+ <td class="px-4 py-3 text-sm"><%= rsb_admin_badge(request.status) %></td>
67
+ <td class="px-4 py-3 text-sm"><%= request.amount_cents %></td>
68
+ <td class="px-4 py-3 text-sm"><%= request.currency.upcase %></td>
69
+ <td class="px-4 py-3 text-sm text-rsb-muted"><%= request.created_at&.strftime("%Y-%m-%d %H:%M") %></td>
70
+ <td class="px-4 py-3 text-sm text-right">
71
+ <a href="/admin/payment_requests/<%= request.id %>"
72
+ class="inline-flex items-center gap-1 px-3 py-1.5 border border-rsb-border text-rsb-text rounded-rsb text-xs font-medium hover:bg-rsb-bg transition-colors">View</a>
73
+ </td>
74
+ </tr>
75
+ <% end %>
76
+ </tbody>
77
+ </table>
78
+ </div>
79
+
80
+ <div class="flex justify-between items-center px-4 py-3 border-t border-rsb-border">
81
+ <span class="text-sm text-rsb-muted">Page <%= @current_page + 1 %></span>
82
+ <div class="flex gap-2">
83
+ <% if @current_page > 0 %>
84
+ <a href="/admin/payment_requests?page=<%= @current_page - 1 %><%= "&status=#{params[:status]}" if params[:status].present? %><%= "&provider_key=#{params[:provider_key]}" if params[:provider_key].present? %>"
85
+ class="inline-flex items-center px-3 py-1.5 border border-rsb-border text-rsb-text rounded-rsb text-xs font-medium hover:bg-rsb-bg transition-colors">Previous</a>
86
+ <% end %>
87
+ <% if @payment_requests.size == @per_page %>
88
+ <a href="/admin/payment_requests?page=<%= @current_page + 1 %><%= "&status=#{params[:status]}" if params[:status].present? %><%= "&provider_key=#{params[:provider_key]}" if params[:provider_key].present? %>"
89
+ class="inline-flex items-center px-3 py-1.5 border border-rsb-border text-rsb-text rounded-rsb text-xs font-medium hover:bg-rsb-bg transition-colors">Next</a>
90
+ <% end %>
91
+ </div>
92
+ </div>
93
+ <% else %>
94
+ <div class="p-8 text-center">
95
+ <p class="text-rsb-muted text-sm">No payment requests found.</p>
96
+ </div>
97
+ <% end %>
98
+ </div>
@@ -0,0 +1,137 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">Payment Request #<%= @payment_request.id %></h1>
3
+ <div class="flex gap-2">
4
+ <a href="/admin/payment_requests"
5
+ class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg transition-colors">Back</a>
6
+ </div>
7
+ </div>
8
+
9
+ <%# Request Details %>
10
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
11
+ <h3 class="text-base font-semibold mb-4">Request Information</h3>
12
+ <div class="grid grid-cols-[200px_1fr] gap-4">
13
+ <strong class="text-sm text-rsb-muted">ID</strong>
14
+ <div><%= @payment_request.id %></div>
15
+
16
+ <strong class="text-sm text-rsb-muted">Requestable</strong>
17
+ <div><%= @payment_request.requestable_type.demodulize %> #<%= @payment_request.requestable_id %></div>
18
+
19
+ <strong class="text-sm text-rsb-muted">Plan</strong>
20
+ <div><%= @payment_request.plan&.name %></div>
21
+
22
+ <strong class="text-sm text-rsb-muted">Provider</strong>
23
+ <div><%= rsb_admin_badge(@payment_request.provider_key) %></div>
24
+
25
+ <strong class="text-sm text-rsb-muted">Status</strong>
26
+ <div><%= rsb_admin_badge(@payment_request.status) %></div>
27
+
28
+ <strong class="text-sm text-rsb-muted">Amount</strong>
29
+ <div><%= @payment_request.amount_cents %> <%= @payment_request.currency.upcase %></div>
30
+
31
+ <% if @payment_request.provider_ref.present? %>
32
+ <strong class="text-sm text-rsb-muted">Provider Ref</strong>
33
+ <div><code class="px-1.5 py-0.5 bg-rsb-bg rounded-rsb-sm text-sm"><%= @payment_request.provider_ref %></code></div>
34
+ <% end %>
35
+
36
+ <strong class="text-sm text-rsb-muted">Expires At</strong>
37
+ <div><%= @payment_request.expires_at&.strftime("%Y-%m-%d %H:%M") || "—" %></div>
38
+
39
+ <strong class="text-sm text-rsb-muted">Created At</strong>
40
+ <div><%= @payment_request.created_at&.strftime("%Y-%m-%d %H:%M") %></div>
41
+
42
+ <% if @payment_request.resolved_by.present? %>
43
+ <strong class="text-sm text-rsb-muted">Resolved By</strong>
44
+ <div><%= @payment_request.resolved_by %></div>
45
+
46
+ <strong class="text-sm text-rsb-muted">Resolved At</strong>
47
+ <div><%= @payment_request.resolved_at&.strftime("%Y-%m-%d %H:%M") %></div>
48
+ <% end %>
49
+
50
+ <% if @payment_request.admin_note.present? %>
51
+ <strong class="text-sm text-rsb-muted">Admin Note</strong>
52
+ <div><%= @payment_request.admin_note %></div>
53
+ <% end %>
54
+
55
+ <% if @payment_request.entitlement_id.present? %>
56
+ <strong class="text-sm text-rsb-muted">Entitlement</strong>
57
+ <div>#<%= @payment_request.entitlement_id %></div>
58
+ <% end %>
59
+ </div>
60
+ </div>
61
+
62
+ <%# Provider Details %>
63
+ <% if @provider_details.present? %>
64
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
65
+ <h3 class="text-base font-semibold mb-4">Provider Details</h3>
66
+ <div class="grid grid-cols-[200px_1fr] gap-4">
67
+ <% @provider_details.each do |label, value| %>
68
+ <strong class="text-sm text-rsb-muted"><%= label %></strong>
69
+ <div><%= value %></div>
70
+ <% end %>
71
+ </div>
72
+ </div>
73
+ <% end %>
74
+
75
+ <%# Provider Data JSON %>
76
+ <% if @payment_request.provider_data.present? && @payment_request.provider_data != {} %>
77
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
78
+ <h3 class="text-base font-semibold mb-4">Provider Data</h3>
79
+ <pre class="p-4 bg-rsb-bg rounded-rsb text-sm overflow-x-auto"><%= JSON.pretty_generate(@payment_request.provider_data) %></pre>
80
+ </div>
81
+ <% end %>
82
+
83
+ <%# Metadata JSON %>
84
+ <% if @payment_request.metadata.present? && @payment_request.metadata != {} %>
85
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
86
+ <h3 class="text-base font-semibold mb-4">Metadata</h3>
87
+ <pre class="p-4 bg-rsb-bg rounded-rsb text-sm overflow-x-auto"><%= JSON.pretty_generate(@payment_request.metadata) %></pre>
88
+ </div>
89
+ <% end %>
90
+
91
+ <%# Action Buttons — Approve / Reject %>
92
+ <% if @payment_request.actionable? && @provider_definition&.manual_resolution %>
93
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
94
+ <h3 class="text-base font-semibold mb-4">Actions</h3>
95
+ <div class="flex flex-wrap items-end gap-4">
96
+ <% if @provider_definition.admin_actions.include?(:approve) %>
97
+ <%= form_tag("/admin/payment_requests/#{@payment_request.id}/approve", method: :patch, class: "inline") do %>
98
+ <button type="submit"
99
+ data-turbo-confirm="Approve this payment request?"
100
+ class="px-4 py-2 bg-rsb-success text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors">
101
+ Approve
102
+ </button>
103
+ <% end %>
104
+ <% end %>
105
+
106
+ <% if @provider_definition.admin_actions.include?(:reject) %>
107
+ <%= form_tag("/admin/payment_requests/#{@payment_request.id}/reject", method: :patch, class: "inline-flex items-end gap-2") do %>
108
+ <div>
109
+ <label class="block text-xs font-medium text-rsb-muted mb-1">Note</label>
110
+ <%= text_field_tag :admin_note, "",
111
+ placeholder: "Optional note",
112
+ class: "px-3 py-1.5 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
113
+ </div>
114
+ <button type="submit"
115
+ data-turbo-confirm="Reject this payment request?"
116
+ class="px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors">
117
+ Reject
118
+ </button>
119
+ <% end %>
120
+ <% end %>
121
+ </div>
122
+ </div>
123
+ <% end %>
124
+
125
+ <%# Refund Button %>
126
+ <% if @payment_request.approved? && @provider_definition&.refundable %>
127
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
128
+ <h3 class="text-base font-semibold text-rsb-danger mb-4">Danger Zone</h3>
129
+ <%= form_tag("/admin/payment_requests/#{@payment_request.id}/refund", method: :patch) do %>
130
+ <button type="submit"
131
+ data-turbo-confirm="Refund this payment request? This will revoke the linked entitlement."
132
+ class="px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors">
133
+ Refund
134
+ </button>
135
+ <% end %>
136
+ </div>
137
+ <% end %>