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,202 @@
1
+ <%= form_with model: plan, url: url, method: method, local: true, scope: :plan do |f| %>
2
+ <% if plan.errors.any? %>
3
+ <div class="mb-4 p-4 bg-rsb-danger-bg border border-rsb-danger rounded-rsb">
4
+ <strong class="text-rsb-danger-text"><%= pluralize(plan.errors.count, "error") %> prohibited this plan from being saved:</strong>
5
+ <ul class="mt-2 pl-5 list-disc text-rsb-danger-text text-sm">
6
+ <% plan.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <%# Basic Fields %>
14
+ <div class="mb-4">
15
+ <%= f.label :name, class: "block text-sm font-medium mb-1" %>
16
+ <%= f.text_field :name, required: true, placeholder: "Pro Plan", id: "plan_name_field",
17
+ class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card focus:outline-none focus:ring-2 focus:ring-rsb-primary" %>
18
+ </div>
19
+
20
+ <div class="mb-4">
21
+ <%= f.label :slug, class: "block text-sm font-medium mb-1" %>
22
+ <%= f.text_field :slug, required: true, placeholder: "pro-plan", id: "plan_slug_field",
23
+ class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card focus:outline-none focus:ring-2 focus:ring-rsb-primary" %>
24
+ <p class="mt-1 text-xs text-rsb-muted">Auto-generated from name. Use lowercase letters, numbers, hyphens, and underscores only.</p>
25
+ </div>
26
+
27
+ <div class="mb-4">
28
+ <%= f.label :interval, class: "block text-sm font-medium mb-1" %>
29
+ <%= f.select :interval, [["Monthly", "monthly"], ["Yearly", "yearly"], ["Lifetime", "lifetime"], ["One-time", "one_time"]],
30
+ {}, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card focus:outline-none focus:ring-2 focus:ring-rsb-primary" %>
31
+ </div>
32
+
33
+ <div class="mb-4">
34
+ <%= f.label :price_cents, "Price (cents)", class: "block text-sm font-medium mb-1" %>
35
+ <%= f.number_field :price_cents, required: true, min: 0, placeholder: "2900",
36
+ class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card focus:outline-none focus:ring-2 focus:ring-rsb-primary" %>
37
+ <p class="mt-1 text-xs text-rsb-muted">Enter price in cents. Example: 2900 = $29.00</p>
38
+ </div>
39
+
40
+ <div class="mb-4">
41
+ <%= f.label :currency, class: "block text-sm font-medium mb-1" %>
42
+ <%= f.text_field :currency, required: true, placeholder: "usd",
43
+ class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card focus:outline-none focus:ring-2 focus:ring-rsb-primary" %>
44
+ </div>
45
+
46
+ <div class="mb-4">
47
+ <label class="flex items-center gap-2">
48
+ <%= f.check_box :active, class: "rounded border-rsb-border" %>
49
+ <span class="text-sm">Active plan</span>
50
+ </label>
51
+ <p class="mt-1 text-xs text-rsb-muted">Make this plan available for selection.</p>
52
+ </div>
53
+
54
+ <%# Features Section (dynamic) %>
55
+ <div class="mb-4">
56
+ <label class="block text-sm font-semibold mb-2">Features</label>
57
+ <div id="features-container" class="space-y-2">
58
+ <% if plan.features.present? && plan.features.any? %>
59
+ <% plan.features.each do |key, value| %>
60
+ <div class="flex items-center gap-2">
61
+ <input type="text" value="<%= key %>" placeholder="Feature name" readonly
62
+ class="flex-1 px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-bg">
63
+ <label class="flex items-center gap-1 whitespace-nowrap">
64
+ <input type="hidden" name="plan[features][<%= key %>]" value="false">
65
+ <input type="checkbox" name="plan[features][<%= key %>]" value="true" <%= 'checked' if value %> class="rounded border-rsb-border">
66
+ <span class="text-sm">Enabled</span>
67
+ </label>
68
+ <button type="button" onclick="this.parentElement.remove()"
69
+ class="px-2 py-1 bg-rsb-danger text-white rounded-rsb text-xs font-medium hover:opacity-90 transition-colors">Remove</button>
70
+ </div>
71
+ <% end %>
72
+ <% end %>
73
+ </div>
74
+ <button type="button" id="add-feature-btn"
75
+ class="mt-2 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">+ Add Feature</button>
76
+ </div>
77
+
78
+ <%# Limits Section (dynamic) %>
79
+ <div class="mb-4">
80
+ <label class="block text-sm font-semibold mb-2">Limits</label>
81
+ <div id="limits-container" class="space-y-2">
82
+ <% if plan.limits.present? && plan.limits.any? %>
83
+ <% plan.limits.each do |key, value| %>
84
+ <div class="flex items-center gap-2">
85
+ <input type="text" value="<%= key %>" placeholder="Limit name" readonly
86
+ class="flex-1 px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-bg">
87
+ <input type="number" name="plan[limits][<%= key %>]" value="<%= value %>" placeholder="Value"
88
+ class="w-30 px-3 py-2 border border-rsb-border rounded-rsb text-sm">
89
+ <button type="button" onclick="this.parentElement.remove()"
90
+ class="px-2 py-1 bg-rsb-danger text-white rounded-rsb text-xs font-medium hover:opacity-90 transition-colors">Remove</button>
91
+ </div>
92
+ <% end %>
93
+ <% end %>
94
+ </div>
95
+ <button type="button" id="add-limit-btn"
96
+ class="mt-2 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">+ Add Limit</button>
97
+ </div>
98
+
99
+ <%# Description (from metadata) %>
100
+ <div class="mb-4">
101
+ <label for="plan_metadata_description" class="block text-sm font-medium mb-1">Description</label>
102
+ <textarea name="plan[metadata][description]" id="plan_metadata_description" rows="3"
103
+ placeholder="Describe this plan..."
104
+ class="w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card focus:outline-none focus:ring-2 focus:ring-rsb-primary"><%= plan.metadata&.dig("description") %></textarea>
105
+ </div>
106
+
107
+ <%# Actions %>
108
+ <div class="flex gap-3 mt-6">
109
+ <button type="submit"
110
+ class="px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover transition-colors"><%= plan.new_record? ? "Create Plan" : "Update Plan" %></button>
111
+ <a href="/admin/plans"
112
+ class="px-4 py-2 border border-rsb-border text-rsb-text rounded-rsb text-sm hover:bg-rsb-bg transition-colors">Cancel</a>
113
+ </div>
114
+ <% end %>
115
+
116
+ <script>
117
+ // Auto-generate slug from name
118
+ document.addEventListener('DOMContentLoaded', function() {
119
+ var nameField = document.getElementById('plan_name_field');
120
+ var slugField = document.getElementById('plan_slug_field');
121
+
122
+ if (nameField && slugField) {
123
+ nameField.addEventListener('input', function() {
124
+ if (!slugField.dataset.manuallyEdited) {
125
+ var slug = this.value
126
+ .toLowerCase()
127
+ .replace(/[^a-z0-9]+/g, '-')
128
+ .replace(/^-|-$/g, '');
129
+ slugField.value = slug;
130
+ }
131
+ });
132
+
133
+ slugField.addEventListener('input', function() {
134
+ slugField.dataset.manuallyEdited = 'true';
135
+ });
136
+ }
137
+ });
138
+
139
+ // Add feature row
140
+ document.getElementById('add-feature-btn').addEventListener('click', function() {
141
+ var container = document.getElementById('features-container');
142
+ var timestamp = Date.now();
143
+ var row = document.createElement('div');
144
+ row.className = 'flex items-center gap-2';
145
+ row.innerHTML =
146
+ '<input type="text" id="feature-key-' + timestamp + '" placeholder="Feature name (e.g., sso)" ' +
147
+ 'class="flex-1 px-3 py-2 border border-rsb-border rounded-rsb text-sm">' +
148
+ '<label class="flex items-center gap-1 whitespace-nowrap">' +
149
+ '<input type="hidden" id="feature-hidden-' + timestamp + '" value="false">' +
150
+ '<input type="checkbox" id="feature-value-' + timestamp + '" value="true" class="rounded border-rsb-border">' +
151
+ '<span class="text-sm">Enabled</span>' +
152
+ '</label>' +
153
+ '<button type="button" onclick="this.parentElement.remove()" ' +
154
+ 'class="px-2 py-1 bg-rsb-danger text-white rounded-rsb text-xs font-medium hover:opacity-90 transition-colors">Remove</button>';
155
+ container.appendChild(row);
156
+
157
+ // When the key input changes, update the name attributes
158
+ var keyInput = row.querySelector('#feature-key-' + timestamp);
159
+ var hiddenInput = row.querySelector('#feature-hidden-' + timestamp);
160
+ var checkboxInput = row.querySelector('#feature-value-' + timestamp);
161
+
162
+ keyInput.addEventListener('input', function() {
163
+ var key = this.value.trim();
164
+ if (key) {
165
+ hiddenInput.name = 'plan[features][' + key + ']';
166
+ checkboxInput.name = 'plan[features][' + key + ']';
167
+ } else {
168
+ hiddenInput.name = '';
169
+ checkboxInput.name = '';
170
+ }
171
+ });
172
+ });
173
+
174
+ // Add limit row
175
+ document.getElementById('add-limit-btn').addEventListener('click', function() {
176
+ var container = document.getElementById('limits-container');
177
+ var timestamp = Date.now();
178
+ var row = document.createElement('div');
179
+ row.className = 'flex items-center gap-2';
180
+ row.innerHTML =
181
+ '<input type="text" id="limit-key-' + timestamp + '" placeholder="Limit name (e.g., api_calls)" ' +
182
+ 'class="flex-1 px-3 py-2 border border-rsb-border rounded-rsb text-sm">' +
183
+ '<input type="number" id="limit-value-' + timestamp + '" placeholder="Value" ' +
184
+ 'class="w-30 px-3 py-2 border border-rsb-border rounded-rsb text-sm">' +
185
+ '<button type="button" onclick="this.parentElement.remove()" ' +
186
+ 'class="px-2 py-1 bg-rsb-danger text-white rounded-rsb text-xs font-medium hover:opacity-90 transition-colors">Remove</button>';
187
+ container.appendChild(row);
188
+
189
+ // When the key input changes, update the name attribute on the value input
190
+ var keyInput = row.querySelector('#limit-key-' + timestamp);
191
+ var valueInput = row.querySelector('#limit-value-' + timestamp);
192
+
193
+ keyInput.addEventListener('input', function() {
194
+ var key = this.value.trim();
195
+ if (key) {
196
+ valueInput.name = 'plan[limits][' + key + ']';
197
+ } else {
198
+ valueInput.name = '';
199
+ }
200
+ });
201
+ });
202
+ </script>
@@ -0,0 +1,9 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">Edit <%= @plan.name %></h1>
3
+ </div>
4
+
5
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
6
+ <%= render "form", plan: @plan,
7
+ url: "/admin/plans/#{@plan.id}",
8
+ method: :patch %>
9
+ </div>
@@ -0,0 +1,74 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">Plans</h1>
3
+ <div class="flex gap-2">
4
+ <a href="/admin/plans/new"
5
+ class="inline-flex items-center gap-1 px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover transition-colors">New Plan</a>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm overflow-hidden">
10
+ <% if @plans.any? %>
11
+ <div class="overflow-x-auto">
12
+ <table class="w-full">
13
+ <thead>
14
+ <tr>
15
+ <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">Name</th>
16
+ <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">Slug</th>
17
+ <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">Interval</th>
18
+ <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">Price</th>
19
+ <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">Features</th>
20
+ <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">Limits</th>
21
+ <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>
22
+ <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>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <% @plans.each do |plan| %>
27
+ <tr class="hover:bg-rsb-bg border-b border-rsb-border last:border-b-0">
28
+ <td class="px-4 py-3 text-sm">
29
+ <a href="/admin/plans/<%= plan.id %>" class="text-rsb-primary hover:underline"><%= plan.name %></a>
30
+ </td>
31
+ <td class="px-4 py-3 text-sm text-rsb-muted"><%= plan.slug %></td>
32
+ <td class="px-4 py-3 text-sm">
33
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-info-bg text-rsb-info-text text-xs font-medium"><%= plan.interval.titleize %></span>
34
+ </td>
35
+ <td class="px-4 py-3 text-sm">$<%= "%.2f" % (plan.price_cents / 100.0) %> <%= plan.currency.upcase %></td>
36
+ <td class="px-4 py-3 text-sm"><%= pluralize(plan.features.size, "feature") %></td>
37
+ <td class="px-4 py-3 text-sm"><%= pluralize(plan.limits.size, "limit") %></td>
38
+ <td class="px-4 py-3 text-sm">
39
+ <% if plan.active? %>
40
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-success-bg text-rsb-success-text text-xs font-medium">Active</span>
41
+ <% else %>
42
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-danger-bg text-rsb-danger-text text-xs font-medium">Inactive</span>
43
+ <% end %>
44
+ </td>
45
+ <td class="px-4 py-3 text-sm text-right">
46
+ <a href="/admin/plans/<%= plan.id %>/edit"
47
+ 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">Edit</a>
48
+ </td>
49
+ </tr>
50
+ <% end %>
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+
55
+ <%# Pagination %>
56
+ <div class="flex justify-between items-center px-4 py-3 border-t border-rsb-border">
57
+ <span class="text-sm text-rsb-muted">Page <%= @current_page + 1 %></span>
58
+ <div class="flex gap-2">
59
+ <% if @current_page > 0 %>
60
+ <a href="/admin/plans?page=<%= @current_page - 1 %>"
61
+ 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>
62
+ <% end %>
63
+ <% if @plans.size == @per_page %>
64
+ <a href="/admin/plans?page=<%= @current_page + 1 %>"
65
+ 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>
66
+ <% end %>
67
+ </div>
68
+ </div>
69
+ <% else %>
70
+ <div class="p-8 text-center">
71
+ <p class="text-rsb-muted text-sm">No plans created yet. Create your first subscription plan to get started.</p>
72
+ </div>
73
+ <% end %>
74
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">New Plan</h1>
3
+ </div>
4
+
5
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
6
+ <%= render "form", plan: @plan,
7
+ url: "/admin/plans",
8
+ method: :post %>
9
+ </div>
@@ -0,0 +1,94 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold"><%= @plan.name %></h1>
3
+ <div class="flex gap-2">
4
+ <a href="/admin/plans/<%= @plan.id %>/edit"
5
+ class="inline-flex items-center gap-1 px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover transition-colors">Edit</a>
6
+ <a href="/admin/plans"
7
+ 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>
8
+ </div>
9
+ </div>
10
+
11
+ <%# Plan Details %>
12
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
13
+ <h3 class="text-base font-semibold mb-4">Plan Information</h3>
14
+ <div class="grid grid-cols-[200px_1fr] gap-4">
15
+ <strong class="text-sm text-rsb-muted">Name</strong>
16
+ <div><%= @plan.name %></div>
17
+
18
+ <strong class="text-sm text-rsb-muted">Slug</strong>
19
+ <div><code class="px-1.5 py-0.5 bg-rsb-bg rounded-rsb-sm text-sm"><%= @plan.slug %></code></div>
20
+
21
+ <strong class="text-sm text-rsb-muted">Interval</strong>
22
+ <div>
23
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-info-bg text-rsb-info-text text-xs font-medium"><%= @plan.interval.titleize %></span>
24
+ </div>
25
+
26
+ <strong class="text-sm text-rsb-muted">Price</strong>
27
+ <div>$<%= "%.2f" % (@plan.price_cents / 100.0) %> <%= @plan.currency.upcase %></div>
28
+
29
+ <strong class="text-sm text-rsb-muted">Status</strong>
30
+ <div>
31
+ <% if @plan.active? %>
32
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-success-bg text-rsb-success-text text-xs font-medium">Active</span>
33
+ <% else %>
34
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-danger-bg text-rsb-danger-text text-xs font-medium">Inactive</span>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <%# Features %>
41
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
42
+ <h3 class="text-base font-semibold mb-4">Features</h3>
43
+ <% if @plan.features.any? %>
44
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
45
+ <% @plan.features.each do |key, value| %>
46
+ <div class="flex items-center gap-2">
47
+ <span class="text-sm"><%= key.titleize %></span>
48
+ <% if value %>
49
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-success-bg text-rsb-success-text text-xs font-medium">Enabled</span>
50
+ <% else %>
51
+ <span class="inline-block px-2.5 py-0.5 rounded-rsb bg-rsb-danger-bg text-rsb-danger-text text-xs font-medium">Disabled</span>
52
+ <% end %>
53
+ </div>
54
+ <% end %>
55
+ </div>
56
+ <% else %>
57
+ <p class="text-rsb-muted text-sm">No features configured.</p>
58
+ <% end %>
59
+ </div>
60
+
61
+ <%# Limits %>
62
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
63
+ <h3 class="text-base font-semibold mb-4">Limits</h3>
64
+ <% if @plan.limits.any? %>
65
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
66
+ <% @plan.limits.each do |key, value| %>
67
+ <div>
68
+ <div class="text-sm text-rsb-muted"><%= key.titleize %></div>
69
+ <div class="text-xl font-semibold"><%= number_with_delimiter(value) %></div>
70
+ </div>
71
+ <% end %>
72
+ </div>
73
+ <% else %>
74
+ <p class="text-rsb-muted text-sm">No limits configured.</p>
75
+ <% end %>
76
+ </div>
77
+
78
+ <%# Metadata %>
79
+ <% if @plan.metadata.present? && @plan.metadata.any? %>
80
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
81
+ <h3 class="text-base font-semibold mb-4">Metadata</h3>
82
+ <pre class="p-4 bg-rsb-bg rounded-rsb text-sm overflow-x-auto"><%= JSON.pretty_generate(@plan.metadata) %></pre>
83
+ </div>
84
+ <% end %>
85
+
86
+ <%# Danger Zone %>
87
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
88
+ <h3 class="text-base font-semibold text-rsb-danger mb-4">Danger Zone</h3>
89
+ <%= button_to "Delete Plan",
90
+ "/admin/plans/#{@plan.id}",
91
+ method: :delete,
92
+ data: { turbo_confirm: "Are you sure you want to delete this plan? This action cannot be undone." },
93
+ class: "px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:opacity-90 transition-colors" %>
94
+ </div>
@@ -0,0 +1,110 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">Usage Monitoring</h1>
3
+ <a href="/admin/usage_counters/trend" class="rsb-btn rsb-btn-secondary text-sm">
4
+ Trend Charts
5
+ </a>
6
+ </div>
7
+
8
+ <%# Filters %>
9
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg p-4 mb-4">
10
+ <form method="get" action="/admin/usage_counters" class="flex flex-wrap gap-4 items-end">
11
+ <div>
12
+ <label class="block text-xs font-semibold text-rsb-muted mb-1">Metric</label>
13
+ <select name="metric" class="rsb-input text-sm">
14
+ <option value="">All</option>
15
+ <% @available_metrics.each do |m| %>
16
+ <option value="<%= m %>" <%= "selected" if params[:metric] == m %>><%= m %></option>
17
+ <% end %>
18
+ </select>
19
+ </div>
20
+ <div>
21
+ <label class="block text-xs font-semibold text-rsb-muted mb-1">Period Key</label>
22
+ <input type="text" name="period_key" value="<%= params[:period_key] %>" placeholder="e.g., 2026-02" class="rsb-input text-sm">
23
+ </div>
24
+ <div>
25
+ <label class="block text-xs font-semibold text-rsb-muted mb-1">Countable Type</label>
26
+ <select name="countable_type" class="rsb-input text-sm">
27
+ <option value="">All</option>
28
+ <% @available_types.each do |t| %>
29
+ <option value="<%= t %>" <%= "selected" if params[:countable_type] == t %>><%= t %></option>
30
+ <% end %>
31
+ </select>
32
+ </div>
33
+ <div>
34
+ <button type="submit" class="rsb-btn rsb-btn-primary text-sm">Filter</button>
35
+ <a href="/admin/usage_counters" class="rsb-btn rsb-btn-ghost text-sm ml-2">Clear</a>
36
+ </div>
37
+ </form>
38
+ </div>
39
+
40
+ <%# Table %>
41
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm overflow-hidden">
42
+ <% if @usage_counters.any? %>
43
+ <div class="overflow-x-auto">
44
+ <table class="w-full">
45
+ <thead>
46
+ <tr>
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">
48
+ <a href="/admin/usage_counters?<%= { metric: params[:metric], period_key: params[:period_key], countable_type: params[:countable_type], sort: "period_key", direction: params[:sort] == "period_key" && params[:direction] != "asc" ? "asc" : "desc" }.compact.to_query %>">Period</a>
49
+ </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">Countable</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">Metric</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">
53
+ <a href="/admin/usage_counters?<%= { metric: params[:metric], period_key: params[:period_key], countable_type: params[:countable_type], sort: "current_value", direction: params[:sort] == "current_value" && params[:direction] != "asc" ? "asc" : "desc" }.compact.to_query %>">Value</a>
54
+ </th>
55
+ <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">Limit</th>
56
+ <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">% Used</th>
57
+ <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">
58
+ <a href="/admin/usage_counters?<%= { metric: params[:metric], period_key: params[:period_key], countable_type: params[:countable_type], sort: "created_at", direction: params[:sort] == "created_at" && params[:direction] != "asc" ? "asc" : "desc" }.compact.to_query %>">Created</a>
59
+ </th>
60
+ </tr>
61
+ </thead>
62
+ <tbody>
63
+ <% @usage_counters.each do |counter| %>
64
+ <% pct = counter.limit && counter.limit > 0 ? (counter.current_value * 100.0 / counter.limit).round(1) : nil %>
65
+ <tr class="hover:bg-rsb-bg border-b border-rsb-border last:border-b-0">
66
+ <td class="px-4 py-3 text-sm font-mono"><%= counter.period_key == "__cumulative__" ? "Cumulative" : counter.period_key %></td>
67
+ <td class="px-4 py-3 text-sm"><%= counter.countable_type %>#<%= counter.countable_id %></td>
68
+ <td class="px-4 py-3 text-sm"><span class="rsb-badge"><%= counter.metric %></span></td>
69
+ <td class="px-4 py-3 text-sm font-mono"><%= counter.current_value %></td>
70
+ <td class="px-4 py-3 text-sm font-mono"><%= counter.limit || "&#8734;" %></td>
71
+ <td class="px-4 py-3 text-sm">
72
+ <% if pct %>
73
+ <div class="flex items-center gap-2">
74
+ <div class="w-16 h-2 bg-rsb-bg rounded-full overflow-hidden">
75
+ <div class="h-full rounded-full <%= pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-yellow-500' : 'bg-green-500' %>" style="width: <%= [pct, 100].min %>%"></div>
76
+ </div>
77
+ <span class="text-xs text-rsb-muted"><%= pct %>%</span>
78
+ </div>
79
+ <% else %>
80
+ <span class="text-rsb-muted">&mdash;</span>
81
+ <% end %>
82
+ </td>
83
+ <td class="px-4 py-3 text-sm text-rsb-muted"><%= counter.created_at.strftime("%Y-%m-%d %H:%M") %></td>
84
+ </tr>
85
+ <% end %>
86
+ </tbody>
87
+ </table>
88
+ </div>
89
+
90
+ <%# Pagination %>
91
+ <% total_pages = (@total_count.to_f / @per_page).ceil %>
92
+ <% if total_pages > 1 %>
93
+ <div class="px-4 py-3 border-t border-rsb-border flex justify-between items-center text-sm text-rsb-muted">
94
+ <span>Showing <%= (@page - 1) * @per_page + 1 %>–<%= [@page * @per_page, @total_count].min %> of <%= @total_count %></span>
95
+ <div class="flex gap-2">
96
+ <% if @page > 1 %>
97
+ <a href="/admin/usage_counters?<%= { metric: params[:metric], period_key: params[:period_key], countable_type: params[:countable_type], sort: params[:sort], direction: params[:direction], page: @page - 1 }.compact.to_query %>" class="rsb-btn rsb-btn-ghost text-xs">Prev</a>
98
+ <% end %>
99
+ <% if @page < total_pages %>
100
+ <a href="/admin/usage_counters?<%= { metric: params[:metric], period_key: params[:period_key], countable_type: params[:countable_type], sort: params[:sort], direction: params[:direction], page: @page + 1 }.compact.to_query %>" class="rsb-btn rsb-btn-ghost text-xs">Next</a>
101
+ <% end %>
102
+ </div>
103
+ </div>
104
+ <% end %>
105
+ <% else %>
106
+ <div class="p-8 text-center">
107
+ <p class="text-rsb-muted text-sm">No usage counters found.</p>
108
+ </div>
109
+ <% end %>
110
+ </div>
@@ -0,0 +1,57 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold">Usage Trend</h1>
3
+ <a href="/admin/usage_counters" class="rsb-btn rsb-btn-secondary text-sm">
4
+ Back to Overview
5
+ </a>
6
+ </div>
7
+
8
+ <%# Metric selector %>
9
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg p-4 mb-4">
10
+ <form method="get" action="/admin/usage_counters/trend" class="flex flex-wrap gap-4 items-end">
11
+ <div>
12
+ <label class="block text-xs font-semibold text-rsb-muted mb-1">Metric</label>
13
+ <select name="metric" class="rsb-input text-sm">
14
+ <option value="">Select a metric</option>
15
+ <% @available_metrics.each do |m| %>
16
+ <option value="<%= m %>" <%= "selected" if @metric == m %>><%= m %></option>
17
+ <% end %>
18
+ </select>
19
+ </div>
20
+ <div>
21
+ <label class="block text-xs font-semibold text-rsb-muted mb-1">Countable Type</label>
22
+ <input type="text" name="countable_type" value="<%= params[:countable_type] %>" placeholder="Optional" class="rsb-input text-sm">
23
+ </div>
24
+ <div>
25
+ <label class="block text-xs font-semibold text-rsb-muted mb-1">Countable ID</label>
26
+ <input type="text" name="countable_id" value="<%= params[:countable_id] %>" placeholder="Optional" class="rsb-input text-sm">
27
+ </div>
28
+ <div>
29
+ <button type="submit" class="rsb-btn rsb-btn-primary text-sm">View Trend</button>
30
+ </div>
31
+ </form>
32
+ </div>
33
+
34
+ <%# Chart %>
35
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
36
+ <% if @metric.blank? %>
37
+ <div class="text-center py-8">
38
+ <p class="text-rsb-muted text-sm">Select a metric to view its usage trend.</p>
39
+ </div>
40
+ <% elsif @trend_data.blank? || @trend_data.empty? %>
41
+ <div class="text-center py-8">
42
+ <p class="text-rsb-muted text-sm">No data found for metric "<%= @metric %>".</p>
43
+ </div>
44
+ <% else %>
45
+ <h2 class="text-lg font-semibold mb-4"><%= @metric %> — Last <%= @trend_data.size %> periods</h2>
46
+ <div class="flex items-end gap-1" style="height: 200px;">
47
+ <% @trend_data.each do |period_key, value| %>
48
+ <% bar_pct = @max_value > 0 ? (value.to_f / @max_value * 100).round(1) : 0 %>
49
+ <div class="flex-1 flex flex-col items-center justify-end h-full">
50
+ <span class="text-xs text-rsb-muted mb-1"><%= value %></span>
51
+ <div class="w-full bg-blue-500 rounded-t" style="height: <%= bar_pct %>%; min-height: 2px;" title="<%= period_key %>: <%= value %>"></div>
52
+ <span class="text-xs text-rsb-muted mt-1 truncate w-full text-center" style="font-size: 0.6rem;"><%= period_key == "__cumulative__" ? "Cum." : period_key.gsub("2026-", "") %></span>
53
+ </div>
54
+ <% end %>
55
+ </div>
56
+ <% end %>
57
+ </div>
@@ -0,0 +1,25 @@
1
+ en:
2
+ rsb:
3
+ admin:
4
+ resources:
5
+ plans:
6
+ label: "Plans"
7
+ columns:
8
+ price_cents: "Price"
9
+ entitlements:
10
+ label: "Entitlements"
11
+ columns:
12
+ entitleable_type: "Type"
13
+ entitleable_id: "Owner ID"
14
+ payment_requests:
15
+ label: "Payment Requests"
16
+ columns:
17
+ requestable_type: "Type"
18
+ requestable_id: "Owner ID"
19
+ provider_key: "Provider"
20
+ amount_cents: "Amount"
21
+ resolved_by: "Resolved By"
22
+ resolved_at: "Resolved At"
23
+ admin_note: "Admin Note"
24
+ expires_at: "Expires At"
25
+ provider_ref: "Provider Ref"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRSBEntitlementsPlans < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :rsb_entitlements_plans do |t|
6
+ t.string :name, null: false
7
+ t.string :slug, null: false
8
+ t.string :interval, null: false
9
+ t.integer :price_cents, null: false, default: 0
10
+ t.string :currency, null: false, default: 'usd'
11
+ t.json :features, default: {}
12
+ t.json :limits, default: {}
13
+ t.json :metadata, default: {}
14
+ t.boolean :active, default: true
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :rsb_entitlements_plans, :slug, unique: true
19
+ add_index :rsb_entitlements_plans, :active
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRSBEntitlementsEntitlements < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :rsb_entitlements_entitlements do |t|
6
+ t.references :entitleable, polymorphic: true, null: false
7
+ t.references :plan, null: false, foreign_key: { to_table: :rsb_entitlements_plans }
8
+ t.string :status, null: false, default: 'pending'
9
+ t.string :provider, null: false
10
+ t.string :provider_ref
11
+ t.datetime :activated_at
12
+ t.datetime :expires_at
13
+ t.datetime :revoked_at
14
+ t.string :revoke_reason
15
+ t.json :metadata, default: {}
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :rsb_entitlements_entitlements, :status
20
+ add_index :rsb_entitlements_entitlements, :provider
21
+ add_index :rsb_entitlements_entitlements, :expires_at
22
+ end
23
+ end