kaui 4.0.17 → 4.0.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98e5dfbab802d1505c3e5c5e85dcde9ee8c98724f35ad5a007acd2849f75eb6e
4
- data.tar.gz: 5cdcd79cdc9a95951c09bed2b2cbda90f3fe750e59d2adb93e1b83196f0a91a9
3
+ metadata.gz: f667be5cb34e03c3d1b4da18ef422934d9794643606c998cf1ac84f2e6d99f60
4
+ data.tar.gz: e202ccb38314d766896d3b72235de05c2818b74b985cb8b2717ff4850ab0b4ae
5
5
  SHA512:
6
- metadata.gz: fde50f9e9a89e44b0370a9c2b31713735fc3603accab084bede5bf03ca8586da3f12809be9cdf6a098d272ae02e5f7ee5a531d5cd056b5ed1820944f1fd72071
7
- data.tar.gz: 4539ef5e1f52389fb34d1905e31fd58d3c4d9941dc491b0d79fe31db83c1b431fdb5268b6bbe47a652c1f15edb69d0d6e4012898a06f650dc5af516b1470c530
6
+ metadata.gz: 18c8742fd7498cab0431aa9758b7f7ebda0e225d7fba76c9dd8a1fa97bd52a8678812b3a6e0397e1d15fc8128e59f451ee455ac7ebdfe8cb13de888a86101501
7
+ data.tar.gz: ee27c185159e1b564e0a0d16322178ddf3fbbce4c78929a0dfd3747c4c198084f1269f3e7d261047b0a2589b5f08d70be3ab07f8d727dfcc969e372929f7395a
@@ -392,69 +392,12 @@ function isBlank(value) {
392
392
  // data-id = content of the popover,object id; required
393
393
  // title = title of the popover; not required
394
394
  // id = (must be {{id}}-popover) used to close popover when the copy image is clicked; if present; if not present a timeout of 5s will apply; not required
395
+ //
396
+ // Deprecated: the underlying Bootstrap 4 popover('destroy') API was removed in
397
+ // Bootstrap 5 and this implementation has been superseded by setObjectIdTooltip().
398
+ // Kept as a thin shim so existing call sites continue to work.
395
399
  function setObjectIdPopover(){
396
- $(".object-id-popover").each(function(idx, e){
397
- $(this).popover('destroy');
398
- $(this).off("shown.bs.popover");
399
- $(this).data("index", idx);
400
-
401
- $(this).popover({
402
- html: true,
403
- content: function() {
404
- var template = '<div class="{{id}}-content" >' +
405
- '{{id}}&emsp;<i id="{{id}}-copy" class="fa fa-clipboard copy-icon" aria-hidden="true"></i> ' +
406
- '</div>';
407
-
408
- var popover_html = Mustache.render( template , { id: $(this).data("id") });
409
- return popover_html;
410
- },
411
- container: 'body',
412
- trigger: 'hover',
413
- delay: { "show": 100, "hide": 4000 }
414
- });
415
-
416
- $(this).on("show.bs.popover", function(e) {
417
- var currentPopoverIndex = $(this).data('index');
418
- $(".object-id-popover").each(function(idx, e){
419
- var index = $(this).data('index');
420
-
421
- if (currentPopoverIndex != index) {
422
- $(this).popover('hide');
423
- }
424
- });
425
- });
426
-
427
- $(this).on("shown.bs.popover", function(e) {
428
- var objectId = $(this).data('id');
429
- var copyIdImg = $("#" + objectId + "-copy");
430
-
431
- copyIdImg.data("popover",$(this).attr("id"));
432
- copyIdImg.click(function(e){
433
- var id = ($(this).attr("id")).replace('-copy','');
434
- navigator.clipboard.writeText(id);
435
- ajaxInfoAlert("Id [" + id + "] was copied into the clipboard!", 4000);
436
-
437
- if (!isBlank(popover)) {
438
- popover.popover('hide');
439
- }
440
-
441
- });
442
-
443
- });
444
-
445
- });
446
-
447
- // close all object id popover on modal show
448
- $(".modal").on('show.bs.modal',function(e){
449
- $(".object-id-popover").each(function(idx, e) {
450
- $(this).popover('destroy');
451
- });
452
- });
453
-
454
- // check if object id must be restored
455
- $(".modal").on('hide.bs.modal',function(e){
456
- setObjectIdPopover();
457
- });
400
+ setObjectIdTooltip();
458
401
  }
459
402
 
460
403
  // Custom tooltip function for object IDs
@@ -14,6 +14,7 @@ module Kaui
14
14
 
15
15
  @bundle, plans_details = lookup_bundle_and_plan_details(@subscription, @base_product_name)
16
16
  @plans = plans_details.map(&:plan)
17
+ @plan_phases = build_plan_phases_map(plans_details)
17
18
 
18
19
  return unless @plans.empty?
19
20
 
@@ -30,6 +31,7 @@ module Kaui
30
31
  _, plans_details = lookup_bundle_and_plan_details(@subscription)
31
32
  # Use a Set to deal with multiple pricelists
32
33
  @plans = Set.new.merge(plans_details.map(&:plan))
34
+ @plan_phases = build_plan_phases_map(plans_details)
33
35
  end
34
36
 
35
37
  def create
@@ -46,15 +48,7 @@ module Kaui
46
48
  @subscription.plan_name = plan_name
47
49
  requested_date = params[:type_change] == 'DATE' ? params[:requested_date].presence : nil
48
50
 
49
- # price override?
50
- override_fixed_price = begin
51
- plan_details.phases.first.prices.blank?
52
- rescue StandardError
53
- false
54
- end
55
- override_recurring_price = !override_fixed_price
56
- phase_type = @bundle.nil? ? plan_details.phases.first.type : @bundle.subscriptions.first.phase_type
57
- overrides = price_overrides(phase_type, override_fixed_price, override_recurring_price)
51
+ overrides = price_overrides(plan_details)
58
52
  @subscription.price_overrides = overrides if overrides.present?
59
53
  @subscription.quantity = params[:quantity].to_i if params[:quantity].present? && params[:quantity].to_i.positive?
60
54
 
@@ -95,11 +89,9 @@ module Kaui
95
89
 
96
90
  input = { planName: plan_name }
97
91
 
98
- # price override?
99
- current_plan = subscription.prices.select { |price| price['phaseType'] == subscription.phase_type }
100
- override_fixed_price = current_plan.last['recurringPrice'].nil?
101
- override_recurring_price = !override_fixed_price
102
- overrides = price_overrides(subscription.phase_type, override_fixed_price, override_recurring_price)
92
+ _, plans_details = lookup_bundle_and_plan_details(subscription)
93
+ plan_details = plans_details.find { |p| p.plan == plan_name }
94
+ overrides = plan_details ? price_overrides(plan_details) : nil
103
95
  input[:priceOverrides] = overrides if overrides.present?
104
96
 
105
97
  subscription.change_plan(input,
@@ -158,11 +150,113 @@ module Kaui
158
150
  redirect_to kaui_engine.account_bundles_path(input_subscription['account_id']), notice: 'Subscription BCD was successfully changed'
159
151
  end
160
152
 
153
+ def edit_quantity
154
+ @subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient)
155
+ end
156
+
157
+ def update_quantity
158
+ id = params.require(:id)
159
+ input_subscription = params.require(:subscription)
160
+
161
+ quantity_raw = input_subscription['quantity'].to_s.strip
162
+ quantity = Integer(quantity_raw, exception: false)
163
+ if quantity.nil? || quantity <= 0
164
+ flash.now[:error] = 'Quantity must be a positive integer'
165
+ @subscription = Kaui::Subscription.find_by_id(id, 'NONE', options_for_klient)
166
+ @subscription.quantity = quantity_raw
167
+ render :edit_quantity and return
168
+ end
169
+
170
+ subscription = Kaui::Subscription.new
171
+ subscription.subscription_id = id
172
+ subscription.quantity = quantity
173
+
174
+ effective_from_date = params['effective_from_date']
175
+
176
+ subscription.update_quantity(current_user.kb_username, params[:reason], params[:comment], effective_from_date, nil, options_for_klient)
177
+ redirect_to kaui_engine.account_bundles_path(input_subscription['account_id']), notice: 'Subscription quantity was successfully changed'
178
+ rescue ActionController::ParameterMissing
179
+ redirect_to kaui_engine.edit_quantity_path(params[:id]), flash: { error: 'Required parameter missing: subscription' }
180
+ end
181
+
182
+ def record_usage
183
+ @subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient)
184
+ @unit_types = fetch_unit_types_from_subscription(@subscription)
185
+ end
186
+
187
+ def create_usage
188
+ subscription_id = params.require(:id)
189
+ unit_type = params[:unit_type].to_s.strip
190
+ amount_raw = params[:amount].to_s.strip
191
+ record_date = params[:record_date].to_s.strip
192
+
193
+ # Input validation
194
+ errors = []
195
+ errors << 'Unit type is required' if unit_type.blank?
196
+ errors << 'Amount is required' if amount_raw.blank?
197
+ amount = Integer(amount_raw, exception: false)
198
+ errors << 'Amount must be a positive integer' if amount.nil? || amount <= 0
199
+ errors << 'Date/time of usage is required' if record_date.blank?
200
+ parsed_date = parse_usage_date(record_date) if record_date.present?
201
+ errors << 'Date/time of usage must be a valid date or datetime' if record_date.present? && parsed_date.nil?
202
+
203
+ if errors.any?
204
+ flash.now[:error] = errors.join('. ')
205
+ @subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
206
+ @unit_types = fetch_unit_types_from_subscription(@subscription)
207
+ @unit_type = unit_type
208
+ @amount = amount_raw
209
+ @record_date = record_date
210
+ @tracking_id = params[:tracking_id]
211
+ render :record_usage and return
212
+ end
213
+
214
+ begin
215
+ usage_record = KillBillClient::Model::UsageRecordAttributes.new
216
+ usage_record.record_date = parsed_date.utc.iso8601
217
+ usage_record.amount = amount
218
+
219
+ unit_usage_record = KillBillClient::Model::UnitUsageRecordAttributes.new
220
+ unit_usage_record.unit_type = unit_type
221
+ unit_usage_record.usage_records = [usage_record]
222
+
223
+ usage = Kaui::Usage.new
224
+ usage.subscription_id = subscription_id
225
+ usage.tracking_id = params[:tracking_id].presence
226
+ usage.unit_usage_records = [unit_usage_record]
227
+
228
+ usage.create(current_user.kb_username, nil, nil, options_for_klient)
229
+
230
+ subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
231
+ redirect_to kaui_engine.account_bundles_path(subscription.account_id), notice: 'Usage was successfully recorded'
232
+ rescue StandardError => e
233
+ Rails.logger.error("Failed to record usage for subscription #{subscription_id}: #{e.class}: #{e.message}")
234
+ Rails.logger.error(e.backtrace.join("\n")) if e.backtrace
235
+ flash.now[:error] = "Error while recording usage: #{as_string(e)}"
236
+ @subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
237
+ @unit_types = fetch_unit_types_from_subscription(@subscription)
238
+ @unit_type = unit_type
239
+ @amount = amount_raw
240
+ @record_date = record_date
241
+ @tracking_id = params[:tracking_id]
242
+ render :record_usage
243
+ end
244
+ end
245
+
161
246
  def restful_show
162
247
  subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient)
163
248
  redirect_to kaui_engine.account_bundles_path(subscription.account_id)
164
249
  end
165
250
 
251
+ def show_json
252
+ raw_body = Kaui::Subscription.find_raw_by_id(params.require(:id), 'NONE', options_for_klient)
253
+ render body: raw_body, content_type: 'application/json'
254
+ rescue KillBillClient::API::ResponseError => e
255
+ render body: e.response.body, content_type: 'application/json', status: e.code
256
+ rescue StandardError => e
257
+ render json: { error: e.message }, status: :internal_server_error
258
+ end
259
+
166
260
  def validate_bundle_external_key
167
261
  json_response do
168
262
  external_key = params.require(:external_key)
@@ -254,19 +348,119 @@ module Kaui
254
348
  plans
255
349
  end
256
350
 
257
- def price_overrides(phase_type, override_fixed_price, override_recurring_price)
258
- return nil if params[:price_override].blank? || params[:price_override].to_i.negative?
351
+ def price_overrides(plan_details)
352
+ raw = params[:price_overrides]
353
+ return nil if raw.blank?
354
+
355
+ entries = raw.respond_to?(:values) ? raw.values : Array(raw)
356
+ phase_meta = (plan_details.phases || []).index_by(&:type)
357
+
358
+ overrides = entries.filter_map do |entry|
359
+ entry = entry.to_unsafe_h if entry.respond_to?(:to_unsafe_h)
360
+ entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
361
+
362
+ price_str = entry[:price].to_s.strip
363
+ phase_type = entry[:phase_type].to_s.strip
364
+
365
+ price = BigDecimal(price_str, exception: false)
366
+
367
+ next if price.nil? || phase_type.blank? || price.negative?
368
+
369
+ phase = phase_meta[phase_type]
370
+ next if phase.nil?
371
+
372
+ override = KillBillClient::Model::PhasePriceAttributes.new
373
+ override.phase_type = phase_type
374
+ if phase_uses_fixed_price?(phase)
375
+ override.fixed_price = price.to_s('F')
376
+ else
377
+ override.recurring_price = price.to_s('F')
378
+ end
379
+ override
380
+ end
381
+
382
+ overrides.presence
383
+ end
384
+
385
+ def build_plan_phases_map(plans_details)
386
+ (plans_details || []).to_h do |pd|
387
+ phases = (pd.phases || []).map do |ph|
388
+ fixed = phase_uses_fixed_price?(ph)
389
+ prices = ph.prices || []
390
+ price_label = if fixed
391
+ '$0.00'
392
+ elsif prices.any?
393
+ format('$%.2f', prices.first.value.to_f)
394
+ else
395
+ ''
396
+ end
397
+ { type: ph.type, fixed: fixed, priceLabel: price_label }
398
+ end
399
+ [pd.plan, phases]
400
+ end
401
+ end
259
402
 
260
- price_override = params[:price_override]
261
- overrides = []
262
- override = KillBillClient::Model::PhasePriceAttributes.new
263
- override.phase_type = phase_type
264
- override.fixed_price = price_override if override_fixed_price
265
- override.recurring_price = price_override if override_recurring_price
403
+ def phase_uses_fixed_price?(phase)
404
+ (phase.prices || []).empty?
405
+ end
406
+
407
+ def fetch_unit_types_from_subscription(subscription)
408
+ unit_types = []
409
+ (subscription.prices || []).each do |phase_price|
410
+ usage_prices = if phase_price.is_a?(Hash)
411
+ phase_price['usagePrices'] || phase_price[:usagePrices] || []
412
+ elsif phase_price.respond_to?(:usage_prices)
413
+ phase_price.usage_prices || []
414
+ else
415
+ []
416
+ end
417
+ (usage_prices || []).each do |usage_price|
418
+ tier_prices = if usage_price.is_a?(Hash)
419
+ usage_price['tierPrices'] || usage_price[:tierPrices] || []
420
+ elsif usage_price.respond_to?(:tier_prices)
421
+ usage_price.tier_prices || []
422
+ else
423
+ []
424
+ end
425
+ (tier_prices || []).each do |tier_price|
426
+ block_prices = if tier_price.is_a?(Hash)
427
+ tier_price['blockPrices'] || tier_price[:blockPrices] || []
428
+ elsif tier_price.respond_to?(:block_prices)
429
+ tier_price.block_prices || []
430
+ else
431
+ []
432
+ end
433
+ (block_prices || []).each do |block_price|
434
+ unit_name = if block_price.is_a?(Hash)
435
+ block_price['unitName'] || block_price[:unitName] || block_price['unit_name']
436
+ elsif block_price.respond_to?(:unit_name)
437
+ block_price.unit_name
438
+ end
439
+ unit_types << unit_name if unit_name.present?
440
+ end
441
+ end
442
+ end
443
+ end
444
+ unit_types.uniq
445
+ rescue StandardError => e
446
+ Rails.logger.warn("Failed to extract unit types from subscription #{subscription&.subscription_id}: #{e.class}: #{e.message}")
447
+ []
448
+ end
266
449
 
267
- overrides << override
450
+ def parse_usage_date(str)
451
+ str = str.to_s.strip
452
+ return nil if str.empty?
268
453
 
269
- overrides
454
+ if str.match?(/^\d{4}-\d{2}-\d{2}$/)
455
+ year, month, day = str.split('-').map(&:to_i)
456
+ Time.utc(year, month, day)
457
+ elsif str.match?(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
458
+ Time.iso8601("#{str}:00Z")
459
+ else
460
+ Time.iso8601(str)
461
+ end
462
+ rescue ArgumentError
463
+ nil
270
464
  end
271
465
  end
272
466
  end
@@ -189,6 +189,34 @@ module Kaui
189
189
  sub.present? and sub.billing_end_date.present?
190
190
  end
191
191
 
192
+ def subscription_has_usage?(sub, catalog = nil)
193
+ return false if sub.blank?
194
+
195
+ # First try the subscription's resolved per-phase prices
196
+ # (populated by GET /subscriptions/{id} but not by the bundles listing endpoint).
197
+ (sub.prices || []).each do |phase_price|
198
+ usage_prices = if phase_price.is_a?(Hash)
199
+ phase_price['usagePrices'] || phase_price[:usagePrices] ||
200
+ phase_price['usage_prices'] || phase_price[:usage_prices]
201
+ elsif phase_price.respond_to?(:usage_prices)
202
+ phase_price.usage_prices
203
+ end
204
+ return true if usage_prices.present?
205
+ end
206
+
207
+ # Fall back to the catalog plan definition
208
+ return false if catalog.blank? || sub.plan_name.blank?
209
+
210
+ product = catalog.products&.find { |p| p.name == sub.product_name }
211
+ plan = product&.plans&.find { |p| p.name == sub.plan_name }
212
+ return false if plan.nil?
213
+
214
+ (plan.phases || []).any? do |phase|
215
+ usages = phase.respond_to?(:usages) ? phase.usages : nil
216
+ usages.present?
217
+ end
218
+ end
219
+
192
220
  def paging_button_class(num, current_page)
193
221
  num == current_page ? 'btn btn-primary' : 'btn btn-custom'
194
222
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaui
4
+ class Usage < KillBillClient::Model::UsageRecord
5
+ end
6
+ end
@@ -61,9 +61,9 @@
61
61
  <% end %>
62
62
 
63
63
 
64
- <% if @bundles.empty? %>
64
+ <% if @bundles.empty? && @search_query.present? %>
65
65
  <div class="custom-alert custom-alert-info mt-3">
66
- <span>No bundles found<%= @search_query.present? ? " for the given search query." : "." %></span>
66
+ <span>No bundles found for the given search query.</span>
67
67
  </div>
68
68
  <% end %>
69
69
 
@@ -17,10 +17,11 @@
17
17
 
18
18
  <ul class="dropdown-menu header-menu shadow border-0 rounded-3"
19
19
  id="<%= dropdown_id %>_menu"
20
+ aria-labelledby="<%= dropdown_id %>_button"
20
21
  style="min-width: 6.25rem; display: none;">
21
22
  <% menu_items&.each do |item| %>
22
23
  <li>
23
- <%= link_to item[:path], class: "dropdown-item d-flex align-items-center gap-2" do %>
24
+ <%= link_to item[:path], class: "dropdown-item d-flex align-items-center gap-2", data: (item[:data] || {}), method: item[:method] do %>
24
25
  <%= image_tag(item[:icon], alt: "#{item[:label]} Icon", size: "16x16") if item[:icon].present? %>
25
26
  <span class="text-black-700 fs-6 text-normal" style="font-weight: 500; font-size: 0.875rem !important; line-height: 1.25rem;"><%= item[:label] %></span>
26
27
  <% end %>
@@ -31,28 +32,21 @@
31
32
 
32
33
  <%= javascript_tag do %>
33
34
  document.addEventListener('DOMContentLoaded', function () {
34
- const button = document.getElementById('<%= dropdown_id %>_button');
35
- const menu = document.getElementById('<%= dropdown_id %>_menu');
36
- const wrapper = document.getElementById('<%= dropdown_id %>_wrapper');
35
+ var button = document.getElementById('<%= dropdown_id %>_button');
36
+ var menu = document.getElementById('<%= dropdown_id %>_menu');
37
+ var wrapper = document.getElementById('<%= dropdown_id %>_wrapper');
37
38
 
38
39
  button.addEventListener('click', function (e) {
39
40
  e.stopPropagation();
40
- const isShown = menu.style.display === 'block';
41
-
42
- // Close all other open dropdowns first (including other dots menus)
43
- document.querySelectorAll('.dropdown-menu').forEach(function (m) {
44
- if (m !== menu) {
45
- m.style.display = 'none';
46
- }
47
- });
48
-
49
- // Toggle this one
41
+ var isShown = menu.style.display === 'block';
50
42
  menu.style.display = isShown ? 'none' : 'block';
43
+ button.setAttribute('aria-expanded', !isShown);
51
44
  });
52
45
 
53
46
  document.addEventListener('click', function (e) {
54
- if (!wrapper.contains(e.target)) {
47
+ if (!wrapper.contains(e.target) && menu.style.display === 'block') {
55
48
  menu.style.display = 'none';
49
+ button.setAttribute('aria-expanded', 'false');
56
50
  }
57
51
  });
58
52
  });
@@ -45,10 +45,7 @@
45
45
  type: "button",
46
46
  html_class: "kaui-button custom-hover",
47
47
  html_options: {
48
- id: "dropdownMenu1",
49
- data: {
50
- bs_toggle: "dropdown"
51
- }
48
+ id: "dropdownMenu1"
52
49
  }
53
50
  } %>
54
51
  <ul class="dropdown-menu" id="column-visibility" aria-labelledby="dropdownMenu1">
@@ -213,13 +210,15 @@
213
210
 
214
211
  <%= javascript_tag do %>
215
212
  $(document).ready(function() {
216
- // Initialize Bootstrap dropdown
217
- var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'));
218
- var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
219
- return new bootstrap.Dropdown(dropdownToggleEl);
213
+ // Toggle Edit Columns dropdown
214
+ $('#dropdownMenu1').on('click', function(e) {
215
+ e.stopPropagation();
216
+ var $menu = $('#column-visibility');
217
+ $menu.toggleClass('show');
218
+ $(this).attr('aria-expanded', $menu.hasClass('show'));
220
219
  });
221
220
 
222
- $('.dropdown-menu').on('click', 'input[type="checkbox"], label', function(event) {
221
+ $('#column-visibility').on('click', 'input[type="checkbox"], label', function(event) {
223
222
  event.stopPropagation();
224
223
  });
225
224
 
@@ -45,10 +45,7 @@
45
45
  type: "button",
46
46
  html_class: "kaui-button custom-hover",
47
47
  html_options: {
48
- id: "dropdownMenu1",
49
- data: {
50
- bs_toggle: "dropdown"
51
- }
48
+ id: "dropdownMenu1"
52
49
  }
53
50
  } %>
54
51
  <ul class="dropdown-menu" id="column-visibility" aria-labelledby="dropdownMenu1">
@@ -213,13 +210,15 @@
213
210
 
214
211
  <%= javascript_tag do %>
215
212
  $(document).ready(function() {
216
- // Initialize Bootstrap dropdown
217
- var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'));
218
- var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
219
- return new bootstrap.Dropdown(dropdownToggleEl);
213
+ // Toggle Edit Columns dropdown
214
+ $('#dropdownMenu1').on('click', function(e) {
215
+ e.stopPropagation();
216
+ var $menu = $('#column-visibility');
217
+ $menu.toggleClass('show');
218
+ $(this).attr('aria-expanded', $menu.hasClass('show'));
220
219
  });
221
220
 
222
- $('.dropdown-menu').on('click', 'input[type="checkbox"], label', function(event) {
221
+ $('#column-visibility').on('click', 'input[type="checkbox"], label', function(event) {
223
222
  event.stopPropagation();
224
223
  });
225
224
 
@@ -7,9 +7,49 @@
7
7
  </div>
8
8
 
9
9
  <div class="form-group d-flex pb-3 border-bottom mb-3">
10
- <%= label_tag :price_override, 'Price Override', :class => 'col-sm-3 control-label' %>
11
- <div class="col-sm-9">
12
- <%= number_field_tag :price_override, nil, min: 0, :step => :any, :class => 'form-control' %>
10
+ <div class="col-sm-3 control-label" style="padding-top: 2px;">
11
+ <div id="toggle_phase_overrides" style="cursor: pointer; user-select: none;">
12
+ <span id="toggle_phase_overrides_caret">&#9662;</span>
13
+ <strong>Phase Overrides</strong>
14
+ </div>
15
+ </div>
16
+ <div class="col-sm-9" id="phase_overrides_section">
17
+ <p class="text-muted mb-2" style="font-size: 13px;">Override prices for specific phases. All other phases will use catalog pricing.</p>
18
+ <div id="phase_overrides_headers" class="d-flex mb-1 gap-2" style="display: none;">
19
+ <div style="flex: 1; padding-left: 2px;"><small><strong>Phase</strong></small></div>
20
+ <div style="flex: 1; padding-left: 2px;">
21
+ <small><strong>Override Price</strong></small>
22
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle; margin-left: 3px;" title="Leave blank to use catalog pricing">
23
+ <circle cx="8" cy="8" r="7" stroke="#A4A7AE" stroke-width="1.25"/>
24
+ <path d="M8 7v5M8 5.5v-.5" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round"/>
25
+ </svg>
26
+ </div>
27
+ <div style="width: 40px;"></div>
28
+ </div>
29
+ <div id="phase_overrides_rows"></div>
30
+ <button type="button" id="add_phase_override" class="btn w-100 mt-2 d-flex align-items-center justify-content-center gap-1"
31
+ style="border: 1px dashed #d0d5dd; background: white; color: #2563eb; font-size: 14px;">
32
+ <span style="font-size: 16px; line-height: 1;">+</span> Add another phase
33
+ </button>
34
+ <script type="application/json" id="plan_phases_data"><%= raw json_escape((@plan_phases || {}).to_json) %></script>
35
+ <style>.phase-override-remove:hover svg path { stroke: #D92D20; }</style>
36
+ <template id="phase_override_row_template">
37
+ <div class="phase-override-row d-flex align-items-center mb-2 gap-2">
38
+ <select name="price_overrides[][phase_type]" class="form-control phase-override-type" style="flex: 1;"></select>
39
+ <div class="input-group phase-override-price-group" style="flex: 1;">
40
+ <span class="input-group-text">$</span>
41
+ <input type="number" name="price_overrides[][price]" step="any" min="0" placeholder="0.00" class="form-control phase-override-price">
42
+ </div>
43
+ <button type="button" class="btn btn-outline-secondary phase-override-remove" style="padding: 6px 10px; flex-shrink: 0;" aria-label="Remove">
44
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
45
+ <path d="M13 3.66699L12.4093 13.4143C12.3666 14.1181 11.7834 14.667 11.0783 14.667H4.92164C4.21659 14.667 3.6334 14.1181 3.59075 13.4143L3 3.66699" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
46
+ <path d="M2 3.66634H5.33333M5.33333 3.66634L6.16017 1.73706C6.26522 1.49194 6.50625 1.33301 6.77293 1.33301H9.22707C9.49373 1.33301 9.7348 1.49194 9.8398 1.73706L10.6667 3.66634M5.33333 3.66634H10.6667M14 3.66634H10.6667" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
47
+ <path d="M6.33325 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
48
+ <path d="M9.66675 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
49
+ </svg>
50
+ </button>
51
+ </div>
52
+ </template>
13
53
  </div>
14
54
  </div>
15
55
  <div class="form-group d-flex pb-3" id="radio_group">
@@ -106,5 +146,99 @@ $(document).ready(function() {
106
146
  $( "input[type=radio]").on("click", function() {
107
147
  onTypeChange();
108
148
  });
149
+ initPhaseOverrides();
109
150
  });
151
+
152
+ function initPhaseOverrides() {
153
+ var planPhases = {};
154
+ try { planPhases = JSON.parse($('#plan_phases_data').text() || '{}'); } catch (e) { planPhases = {}; }
155
+
156
+ var $section = $('#phase_overrides_section');
157
+ var $rows = $('#phase_overrides_rows');
158
+ var $addBtn = $('#add_phase_override');
159
+ var $headers = $('#phase_overrides_headers');
160
+ var $template = $('#phase_override_row_template');
161
+ var $planSelect = $('select[name="plan_name"]');
162
+
163
+ function currentPhases() {
164
+ return planPhases[$planSelect.val()] || [];
165
+ }
166
+
167
+ function phaseLabel(p) {
168
+ var title = p.type.charAt(0) + p.type.slice(1).toLowerCase();
169
+ return p.priceLabel ? title + ' (' + p.priceLabel + ')' : title;
170
+ }
171
+
172
+ function selectedTypes($exceptRow) {
173
+ return $rows.find('.phase-override-row').filter(function() {
174
+ return !$exceptRow || this !== $exceptRow[0];
175
+ }).map(function() { return $(this).find('.phase-override-type').val(); }).get();
176
+ }
177
+
178
+ function refreshAddBtn() {
179
+ var phases = currentPhases();
180
+ var used = selectedTypes();
181
+ var hasMore = phases.length > 0 && used.length < phases.length;
182
+ $addBtn.toggle(hasMore);
183
+ $headers.css('display', $rows.find('.phase-override-row').length > 0 ? 'flex' : 'none');
184
+ }
185
+
186
+ function rebuildRowOptions() {
187
+ var phases = currentPhases();
188
+ $rows.find('.phase-override-row').each(function() {
189
+ var $row = $(this);
190
+ var $select = $row.find('.phase-override-type');
191
+ var current = $select.val();
192
+ var used = selectedTypes($row);
193
+ $select.empty();
194
+ phases.forEach(function(p) {
195
+ if (used.indexOf(p.type) === -1) {
196
+ $select.append($('<option>', { value: p.type, text: phaseLabel(p) }));
197
+ }
198
+ });
199
+ var currentStillValid = phases.some(function(p) { return p.type === current; }) && used.indexOf(current) === -1;
200
+ if (currentStillValid) $select.val(current);
201
+ });
202
+ refreshAddBtn();
203
+ }
204
+
205
+ function addRow(preferredType) {
206
+ var phases = currentPhases();
207
+ if (phases.length === 0) return;
208
+ var used = selectedTypes();
209
+ var next = preferredType && phases.some(function(p) { return p.type === preferredType; }) && used.indexOf(preferredType) === -1
210
+ ? preferredType
211
+ : (phases.find(function(p) { return used.indexOf(p.type) === -1; }) || {}).type;
212
+ if (!next) return;
213
+
214
+ var $row = $($template.html());
215
+ $rows.append($row);
216
+ rebuildRowOptions();
217
+ $row.find('.phase-override-type').val(next);
218
+ rebuildRowOptions();
219
+ }
220
+
221
+ function resetRows() {
222
+ $rows.empty();
223
+ var phases = currentPhases();
224
+ if (phases.length > 0) addRow(phases[0].type);
225
+ refreshAddBtn();
226
+ }
227
+
228
+ $('#toggle_phase_overrides').on('click', function() {
229
+ $section.toggle();
230
+ $('#toggle_phase_overrides_caret').html($section.is(':visible') ? '&#9662;' : '&#9656;');
231
+ });
232
+
233
+ $addBtn.on('click', function() { addRow(); });
234
+
235
+ $rows.on('change', '.phase-override-type', rebuildRowOptions);
236
+ $rows.on('click', '.phase-override-remove', function() {
237
+ $(this).closest('.phase-override-row').remove();
238
+ refreshAddBtn();
239
+ });
240
+
241
+ $planSelect.on('change', resetRows);
242
+ resetRows();
243
+ }
110
244
  <% end %>
@@ -35,9 +35,49 @@
35
35
  </div>
36
36
  </div>
37
37
  <div class="form-group d-flex pb-3">
38
- <%= label_tag :price_override, 'Price Override', :class => 'col-sm-3 control-label' %>
39
- <div class="col-sm-9">
40
- <%= number_field_tag :price_override, nil, :step => :any, :min => 0, :class => 'form-control' %>
38
+ <div class="col-sm-3 control-label" style="padding-top: 2px;">
39
+ <div id="toggle_phase_overrides" style="cursor: pointer; user-select: none;">
40
+ <span id="toggle_phase_overrides_caret">&#9662;</span>
41
+ <strong>Phase Overrides</strong>
42
+ </div>
43
+ </div>
44
+ <div class="col-sm-9" id="phase_overrides_section">
45
+ <p class="text-muted mb-2" style="font-size: 13px;">Override prices for specific phases. All other phases will use catalog pricing.</p>
46
+ <div id="phase_overrides_headers" class="d-flex mb-1 gap-2" style="display: none;">
47
+ <div style="flex: 1; padding-left: 2px;"><small><strong>Phase</strong></small></div>
48
+ <div style="flex: 1; padding-left: 2px;">
49
+ <small><strong>Override Price</strong></small>
50
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="vertical-align: middle; margin-left: 3px;" title="Leave blank to use catalog pricing">
51
+ <circle cx="8" cy="8" r="7" stroke="#A4A7AE" stroke-width="1.25"/>
52
+ <path d="M8 7v5M8 5.5v-.5" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round"/>
53
+ </svg>
54
+ </div>
55
+ <div style="width: 40px;"></div>
56
+ </div>
57
+ <div id="phase_overrides_rows"></div>
58
+ <button type="button" id="add_phase_override" class="btn w-100 mt-2 d-flex align-items-center justify-content-center gap-1"
59
+ style="border: 1px dashed #d0d5dd; background: white; color: #2563eb; font-size: 14px;">
60
+ <span style="font-size: 16px; line-height: 1;">+</span> Add another phase
61
+ </button>
62
+ <script type="application/json" id="plan_phases_data"><%= raw json_escape((@plan_phases || {}).to_json) %></script>
63
+ <style>.phase-override-remove:hover svg path { stroke: #D92D20; }</style>
64
+ <template id="phase_override_row_template">
65
+ <div class="phase-override-row d-flex align-items-center mb-2 gap-2">
66
+ <select name="price_overrides[][phase_type]" class="form-control phase-override-type" style="flex: 1;"></select>
67
+ <div class="input-group phase-override-price-group" style="flex: 1;">
68
+ <span class="input-group-text">$</span>
69
+ <input type="number" name="price_overrides[][price]" step="any" min="0" placeholder="0.00" class="form-control phase-override-price">
70
+ </div>
71
+ <button type="button" class="btn btn-outline-secondary phase-override-remove" style="padding: 6px 10px; flex-shrink: 0;" aria-label="Remove">
72
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
73
+ <path d="M13 3.66699L12.4093 13.4143C12.3666 14.1181 11.7834 14.667 11.0783 14.667H4.92164C4.21659 14.667 3.6334 14.1181 3.59075 13.4143L3 3.66699" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
74
+ <path d="M2 3.66634H5.33333M5.33333 3.66634L6.16017 1.73706C6.26522 1.49194 6.50625 1.33301 6.77293 1.33301H9.22707C9.49373 1.33301 9.7348 1.49194 9.8398 1.73706L10.6667 3.66634M5.33333 3.66634H10.6667M14 3.66634H10.6667" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
75
+ <path d="M6.33325 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
76
+ <path d="M9.66675 11V7" stroke="#A4A7AE" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
77
+ </svg>
78
+ </button>
79
+ </div>
80
+ </template>
41
81
  </div>
42
82
  </div>
43
83
  <div class="form-group d-flex pb-3 border-bottom mb-3">
@@ -123,5 +163,99 @@ $(document).ready(function() {
123
163
  $( "input[type=radio]").on("click", function() {
124
164
  onTypeChange();
125
165
  });
166
+ initPhaseOverrides();
126
167
  });
168
+
169
+ function initPhaseOverrides() {
170
+ var planPhases = {};
171
+ try { planPhases = JSON.parse($('#plan_phases_data').text() || '{}'); } catch (e) { planPhases = {}; }
172
+
173
+ var $section = $('#phase_overrides_section');
174
+ var $rows = $('#phase_overrides_rows');
175
+ var $addBtn = $('#add_phase_override');
176
+ var $headers = $('#phase_overrides_headers');
177
+ var $template = $('#phase_override_row_template');
178
+ var $planSelect = $('select[name="plan_name"]');
179
+
180
+ function currentPhases() {
181
+ return planPhases[$planSelect.val()] || [];
182
+ }
183
+
184
+ function phaseLabel(p) {
185
+ var title = p.type.charAt(0) + p.type.slice(1).toLowerCase();
186
+ return p.priceLabel ? title + ' (' + p.priceLabel + ')' : title;
187
+ }
188
+
189
+ function selectedTypes($exceptRow) {
190
+ return $rows.find('.phase-override-row').filter(function() {
191
+ return !$exceptRow || this !== $exceptRow[0];
192
+ }).map(function() { return $(this).find('.phase-override-type').val(); }).get();
193
+ }
194
+
195
+ function refreshAddBtn() {
196
+ var phases = currentPhases();
197
+ var used = selectedTypes();
198
+ var hasMore = phases.length > 0 && used.length < phases.length;
199
+ $addBtn.toggle(hasMore);
200
+ $headers.css('display', $rows.find('.phase-override-row').length > 0 ? 'flex' : 'none');
201
+ }
202
+
203
+ function rebuildRowOptions() {
204
+ var phases = currentPhases();
205
+ $rows.find('.phase-override-row').each(function() {
206
+ var $row = $(this);
207
+ var $select = $row.find('.phase-override-type');
208
+ var current = $select.val();
209
+ var used = selectedTypes($row);
210
+ $select.empty();
211
+ phases.forEach(function(p) {
212
+ if (used.indexOf(p.type) === -1) {
213
+ $select.append($('<option>', { value: p.type, text: phaseLabel(p) }));
214
+ }
215
+ });
216
+ var currentStillValid = phases.some(function(p) { return p.type === current; }) && used.indexOf(current) === -1;
217
+ if (currentStillValid) $select.val(current);
218
+ });
219
+ refreshAddBtn();
220
+ }
221
+
222
+ function addRow(preferredType) {
223
+ var phases = currentPhases();
224
+ if (phases.length === 0) return;
225
+ var used = selectedTypes();
226
+ var next = preferredType && phases.some(function(p) { return p.type === preferredType; }) && used.indexOf(preferredType) === -1
227
+ ? preferredType
228
+ : (phases.find(function(p) { return used.indexOf(p.type) === -1; }) || {}).type;
229
+ if (!next) return;
230
+
231
+ var $row = $($template.html());
232
+ $rows.append($row);
233
+ rebuildRowOptions();
234
+ $row.find('.phase-override-type').val(next);
235
+ rebuildRowOptions();
236
+ }
237
+
238
+ function resetRows() {
239
+ $rows.empty();
240
+ var phases = currentPhases();
241
+ if (phases.length > 0) addRow(phases[0].type);
242
+ refreshAddBtn();
243
+ }
244
+
245
+ $('#toggle_phase_overrides').on('click', function() {
246
+ $section.toggle();
247
+ $('#toggle_phase_overrides_caret').html($section.is(':visible') ? '&#9662;' : '&#9656;');
248
+ });
249
+
250
+ $addBtn.on('click', function() { addRow(); });
251
+
252
+ $rows.on('change', '.phase-override-type', rebuildRowOptions);
253
+ $rows.on('click', '.phase-override-remove', function() {
254
+ $(this).closest('.phase-override-row').remove();
255
+ refreshAddBtn();
256
+ });
257
+
258
+ $planSelect.on('change', resetRows);
259
+ resetRows();
260
+ }
127
261
  <% end %>
@@ -111,8 +111,26 @@
111
111
  </span>
112
112
  </td>
113
113
  <td><span class="phase-type"><%= humanized_subscription_phase_type(sub).downcase %></span></td>
114
- <td><%= humanized_subscription_start_date(sub, account) %></td>
115
- <td><%= humanized_subscription_cancelled_information(sub, account) %></td>
114
+ <td>
115
+ <% if sub.start_date.present? %>
116
+ <span class="object-id-popover" data-id="<%= sub.start_date %>" data-title="Start Date (ISO)">
117
+ <%= humanized_subscription_start_date(sub, account) %>
118
+ </span>
119
+ <% end %>
120
+ </td>
121
+ <td>
122
+ <% if sub.cancelled_date.present? %>
123
+ <span class="object-id-popover" data-id="<%= sub.cancelled_date %>" data-title="Entitlement Date (ISO)">
124
+ <%= humanized_subscription_cancelled_date(sub, account) %>
125
+ </span>
126
+ <% if sub.billing_end_date.present? %>
127
+ <br>
128
+ <span class="object-id-popover" data-id="<%= sub.billing_end_date %>" data-title="Billing End Date (ISO)">
129
+ <%= humanized_subscription_billing_end_date(sub, account) %>
130
+ </span>
131
+ <% end %>
132
+ <% end %>
133
+ </td>
116
134
  <td><%= humanized_subscription_charged_through_date(sub, account) %></td>
117
135
  <td><%= sub.quantity || 1 %></td>
118
136
  <td class="text-center">
@@ -141,9 +159,9 @@
141
159
  </div>
142
160
  <% end %>
143
161
 
144
- <% if can?(:create, Kaui::Subscription) || can?(:pause_resume, Kaui::Subscription) || can?(:change_plan, Kaui::Subscription) || can?(:transfer, Kaui::Bundle) %>
145
- <% menu_items = [] %>
162
+ <% menu_items = [] %>
146
163
 
164
+ <% if can?(:create, Kaui::Subscription) || can?(:pause_resume, Kaui::Subscription) || can?(:change_plan, Kaui::Subscription) || can?(:transfer, Kaui::Bundle) || (can?(:record, Kaui::Usage) && subscription_has_usage?(sub, catalog_for_subscription(sub, catalogs))) %>
147
165
  <% if can?(:change_plan, Kaui::Subscription) %>
148
166
  <% menu_items << {
149
167
  label: "Change Plan",
@@ -183,6 +201,22 @@
183
201
  } %>
184
202
  <% end %>
185
203
 
204
+ <% if can?(:create, Kaui::Subscription) %>
205
+ <% menu_items << {
206
+ label: "Update Quantity",
207
+ path: kaui_engine.edit_quantity_path(id: sub.subscription_id),
208
+ icon: "kaui/subscription/change.svg"
209
+ } %>
210
+ <% end %>
211
+
212
+ <% if can?(:record, Kaui::Usage) && subscription_has_usage?(sub, catalog_for_subscription(sub, catalogs)) %>
213
+ <% menu_items << {
214
+ label: "Record Usage",
215
+ path: kaui_engine.record_usage_path(id: sub.subscription_id),
216
+ icon: "kaui/subscription/change.svg"
217
+ } %>
218
+ <% end %>
219
+
186
220
  <% if can?(:transfer, Kaui::Bundle) && sub.product_category != 'ADD_ON' %>
187
221
  <% menu_items << {
188
222
  label: I18n.translate('transfer'),
@@ -190,7 +224,18 @@
190
224
  icon: "kaui/subscription/transfer.svg"
191
225
  } %>
192
226
  <% end %>
227
+ <% end %>
228
+
229
+ <% if can?(:read, Kaui::Subscription) %>
230
+ <% menu_items << {
231
+ label: "View JSON",
232
+ path: "#view_subscription_json_modal",
233
+ icon: "kaui/view-doc.svg",
234
+ data: { bs_toggle: 'modal', id: sub.subscription_id }
235
+ } %>
236
+ <% end %>
193
237
 
238
+ <% if menu_items.any? %>
194
239
  <%= render partial: "kaui/components/menu_dropdown/menu_dropdown", locals: {
195
240
  variant: "btn-light",
196
241
  label: "",
@@ -210,6 +255,7 @@
210
255
  </table>
211
256
 
212
257
  <%= render :partial => 'kaui/subscriptions/cancel_by_date_modal' %>
258
+ <%= render :partial => 'kaui/subscriptions/view_json_modal' %>
213
259
 
214
260
  <%= javascript_tag do %>
215
261
  $(document).ready(function() {
@@ -0,0 +1,100 @@
1
+ <div class="modal fade view_subscription_json_modal" id="view_subscription_json_modal" tabindex="-1" role="dialog" aria-labelledby="viewSubscriptionJsonLabel" aria-hidden="true">
2
+ <div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
3
+ <div class="modal-content">
4
+ <div class="modal-header">
5
+ <h5 class="modal-title d-flex align-items-center gap-3" id="viewSubscriptionJsonLabel">
6
+ <span class="icon-container">
7
+ <%= image_tag("kaui/view-doc.svg", width: 20, height: 20) %>
8
+ </span>
9
+ Subscription JSON
10
+ </h5>
11
+ <button type="button" class="close close-button custom-hover" data-bs-dismiss="modal" aria-label="Close">
12
+ <span aria-hidden="true">
13
+ <%= image_tag("kaui/modal/close.svg", width: 20, height: 20) %>
14
+ </span>
15
+ </button>
16
+ </div>
17
+ <div class="modal-body">
18
+ <div id="view_subscription_json_loading">Loading...</div>
19
+ <div id="view_subscription_json_error" class="text-danger" style="display: none;"></div>
20
+ <pre id="view_subscription_json_content" style="display: none; max-height: 60vh; overflow: auto; background-color: #f8f9fa; padding: 1rem; border-radius: 0.375rem; font-size: 0.8125rem; line-height: 1.4;"></pre>
21
+ </div>
22
+ <div class="modal-footer">
23
+ <%= render "kaui/components/button/button", {
24
+ label: 'Close',
25
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
26
+ type: "button",
27
+ html_class: "kaui-button custom-hover",
28
+ html_options: {
29
+ data: {
30
+ bs_dismiss: "modal"
31
+ }
32
+ }
33
+ } %>
34
+ <%= render "kaui/components/button/button", {
35
+ label: 'Copy JSON',
36
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
37
+ type: "button",
38
+ html_class: "kaui-dropdown custom-hover",
39
+ html_options: {
40
+ id: "copy_subscription_json_btn"
41
+ }
42
+ } %>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+
48
+ <%= javascript_tag do %>
49
+ $(document).ready(function() {
50
+ var $modal = $('#view_subscription_json_modal');
51
+ if (!$modal.length) return;
52
+
53
+ $modal.on('show.bs.modal', function (e) {
54
+ var subId = e.relatedTarget && e.relatedTarget.dataset ? e.relatedTarget.dataset['id'] : null;
55
+ var $loading = $('#view_subscription_json_loading');
56
+ var $error = $('#view_subscription_json_error');
57
+ var $content = $('#view_subscription_json_content');
58
+
59
+ $loading.show();
60
+ $error.hide().text('');
61
+ $content.hide().text('');
62
+
63
+ if (!subId) {
64
+ $loading.hide();
65
+ $error.text('Missing subscription id').show();
66
+ return;
67
+ }
68
+
69
+ $.ajax({
70
+ url: Routes.kaui_engine_show_subscription_json_path(subId),
71
+ type: 'GET',
72
+ dataType: 'json'
73
+ })
74
+ .done(function (data) {
75
+ $content.text(JSON.stringify(data, null, 2)).show();
76
+ $loading.hide();
77
+ })
78
+ .fail(function (xhr) {
79
+ $loading.hide();
80
+ var message = 'Failed to load subscription JSON';
81
+ if (xhr && xhr.responseText) {
82
+ message += ': ' + xhr.responseText;
83
+ }
84
+ $error.text(message).show();
85
+ });
86
+ });
87
+
88
+ $('#copy_subscription_json_btn').on('click', function () {
89
+ var text = $('#view_subscription_json_content').text();
90
+ if (!text) return;
91
+ if (navigator.clipboard && navigator.clipboard.writeText) {
92
+ navigator.clipboard.writeText(text).then(function () {
93
+ if (typeof ajaxInfoAlert === 'function') {
94
+ ajaxInfoAlert('JSON copied to clipboard!', 3000);
95
+ }
96
+ });
97
+ }
98
+ });
99
+ });
100
+ <% end %>
@@ -0,0 +1,59 @@
1
+ <div class="register kaui-subscription-new">
2
+ <div class="">
3
+ <div class="mx-auto" style="max-width: 37.5rem;">
4
+ <h5 class="add-account-title border-bottom pb-3 mb-3">
5
+ <span class="icon-container">
6
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
7
+ <path d="M9.16675 11.6665H13.3334M6.66675 11.6665H6.67423M10.8334 14.9998H6.66675M13.3334 14.9998H13.3259" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
8
+ <path d="M13.75 1.6665V4.99984M6.25 1.6665V4.99984" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
9
+ <path d="M15.8333 3.3335H4.16667C3.24619 3.3335 2.5 4.07969 2.5 5.00016V16.6668C2.5 17.5873 3.24619 18.3335 4.16667 18.3335H15.8333C16.7538 18.3335 17.5 17.5873 17.5 16.6668V5.00016C17.5 4.07969 16.7538 3.3335 15.8333 3.3335Z" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
10
+ <path d="M2.5 8.3335H17.5" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
11
+ </svg>
12
+ </span>
13
+ Update Subscription Quantity
14
+ </h5>
15
+ <%= form_for @subscription, :as => :subscription, :url => update_quantity_path(@subscription.subscription_id), :html => {:method => :put, :class => 'form-horizontal'} do |f| %>
16
+ <%= f.hidden_field :account_id %>
17
+
18
+ <div class="form-group d-flex pb-3">
19
+ <%= f.label :quantity, 'Quantity', :class => 'col-sm-3 control-label' %>
20
+ <div class="col-sm-9">
21
+ <%= f.number_field :quantity, { :min => 1, :required => true, :class => 'form-control'} %>
22
+ </div>
23
+ </div>
24
+ <div class="form-group d-flex pb-3 border-bottom mb-3" id="form_effective_from_date">
25
+ <%= label_tag :effective_from_date, 'Effective Date', :class => 'col-sm-3 control-label' %>
26
+ <div class="col-sm-9">
27
+ <div class="custom-date-input-wrapper kaui-button custom-hover">
28
+ <svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
29
+ <path d="M14.9167 12.5V18.3333M17.8333 15.4167H12" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
30
+ <path d="M14.0833 1.66675V5.00008M6.58325 1.66675V5.00008" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
31
+ <path d="M17.8333 10.8333V4.99992C17.8333 4.07944 17.0871 3.33325 16.1666 3.33325H4.49992C3.57944 3.33325 2.83325 4.07944 2.83325 4.99992V16.6666C2.83325 17.5871 3.57944 18.3333 4.49992 18.3333H10.3333" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
32
+ <path d="M2.83325 8.33325H17.8333" stroke="#A4A7AE" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
33
+ </svg>
34
+ <input class="form-control" value="<%= Time.zone.today.strftime('%Y-%m-%d') %>" id="effective_from_date" name="effective_from_date" type="text" data-provide="datepicker" data-date-format="yyyy-mm-dd" data-date-today-highlight="true">
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <div class="form-group d-flex justify-content-end pb-3">
39
+ <%= render "kaui/components/button/button", {
40
+ label: 'Close',
41
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
42
+ type: "button",
43
+ html_class: "kaui-button custom-hover mx-2",
44
+ html_options: {
45
+ id: "closeButton",
46
+ onclick: "window.history.back();"
47
+ }
48
+ } %>
49
+ <%= render "kaui/components/button/button", {
50
+ label: 'Save Subscription',
51
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
52
+ type: "submit",
53
+ html_class: "kaui-dropdown custom-hover"
54
+ } %>
55
+ </div>
56
+ <% end %>
57
+ </div>
58
+ </div>
59
+ </div>
@@ -0,0 +1,110 @@
1
+ <div class="register kaui-subscription-new">
2
+ <div class="">
3
+ <div class="mx-auto" style="max-width: 37.5rem;">
4
+ <h5 class="add-account-title border-bottom pb-3 mb-3">
5
+ <span class="icon-container">
6
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
7
+ <path d="M9.16675 11.6665H13.3334M6.66675 11.6665H6.67423M10.8334 14.9998H6.66675M13.3334 14.9998H13.3259" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
8
+ <path d="M13.75 1.6665V4.99984M6.25 1.6665V4.99984" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
9
+ <path d="M15.8333 3.3335H4.16667C3.24619 3.3335 2.5 4.07969 2.5 5.00016V16.6668C2.5 17.5873 3.24619 18.3335 4.16667 18.3335H15.8333C16.7538 18.3335 17.5 17.5873 17.5 16.6668V5.00016C17.5 4.07969 16.7538 3.3335 15.8333 3.3335Z" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
10
+ <path d="M2.5 8.3335H17.5" stroke="#414651" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
11
+ </svg>
12
+ </span>
13
+ Record Usage
14
+ </h5>
15
+
16
+ <p class="text-muted small mb-3">
17
+ Record usage for subscription
18
+ <strong><%= @subscription.plan_name %></strong>
19
+ (<code><%= @subscription.subscription_id %></code>).
20
+ </p>
21
+
22
+ <%= form_tag create_usage_path(@subscription.subscription_id), method: :post, id: 'record_usage_form', class: 'form-horizontal' do %>
23
+
24
+ <div class="form-group d-flex pb-3">
25
+ <%= label_tag :unit_type, 'Unit Type', class: 'col-sm-3 control-label' %>
26
+ <div class="col-sm-9">
27
+ <% if @unit_types.length == 1 %>
28
+ <%= text_field_tag :unit_type, @unit_types.first, readonly: true, required: true, class: 'form-control bg-light' %>
29
+ <% elsif @unit_types.length > 1 %>
30
+ <%= select_tag :unit_type, options_for_select(@unit_types.map { |u| [u, u] }, @unit_type.presence || @unit_types.first), required: true, class: 'form-select' %>
31
+ <% else %>
32
+ <%= text_field_tag :unit_type, @unit_type, required: true, maxlength: 255, class: 'form-control', placeholder: 'e.g. api-calls' %>
33
+ <% end %>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="form-group d-flex pb-3">
38
+ <%= label_tag :amount, 'Amount', class: 'col-sm-3 control-label' %>
39
+ <div class="col-sm-9">
40
+ <%= number_field_tag :amount, @amount, required: true, min: 1, step: 1, class: 'form-control', placeholder: 'Positive integer' %>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="form-group d-flex pb-3">
45
+ <%= label_tag :record_date, 'Date/Time of Usage', class: 'col-sm-3 control-label' %>
46
+ <div class="col-sm-9">
47
+ <%= datetime_field_tag :record_date,
48
+ @record_date.presence || Time.now.utc.strftime('%Y-%m-%dT%H:%M'),
49
+ required: true,
50
+ class: 'form-control' %>
51
+ <small class="form-text text-muted">Date and time treated as UTC</small>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="form-group d-flex pb-3">
56
+ <%= label_tag :tracking_id, 'Tracking ID', class: 'col-sm-3 control-label' %>
57
+ <div class="col-sm-9">
58
+ <%= text_field_tag :tracking_id, @tracking_id, maxlength: 255, class: 'form-control', placeholder: 'Optional, used for idempotency' %>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="form-group d-flex justify-content-end pb-3">
63
+ <%= render "kaui/components/button/button", {
64
+ label: 'Close',
65
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
66
+ type: "button",
67
+ html_class: "kaui-button custom-hover mx-2",
68
+ html_options: {
69
+ id: "closeButton",
70
+ onclick: "window.history.back();"
71
+ }
72
+ } %>
73
+ <%= render "kaui/components/button/button", {
74
+ label: 'Record Usage',
75
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
76
+ type: "submit",
77
+ html_class: "kaui-dropdown custom-hover"
78
+ } %>
79
+ </div>
80
+ <% end %>
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <%= javascript_tag do %>
86
+ document.addEventListener('DOMContentLoaded', function () {
87
+ var form = document.getElementById('record_usage_form');
88
+ if (!form) { return; }
89
+ form.addEventListener('submit', function (e) {
90
+ var errors = [];
91
+
92
+ var unitTypeEl = document.getElementById('unit_type');
93
+ var unitType = unitTypeEl ? (unitTypeEl.value || '').trim() : '';
94
+ if (unitType.length === 0) {
95
+ errors.push('Unit type is required.');
96
+ }
97
+
98
+ var amountStr = (document.getElementById('amount').value || '').trim();
99
+ var amount = parseInt(amountStr, 10);
100
+ if (amountStr.length === 0 || isNaN(amount) || amount <= 0 || String(amount) !== amountStr) {
101
+ errors.push('Amount must be a positive integer.');
102
+ }
103
+
104
+ if (errors.length > 0) {
105
+ e.preventDefault();
106
+ alert(errors.join('\n'));
107
+ }
108
+ });
109
+ });
110
+ <% end %>
data/config/routes.rb CHANGED
@@ -141,7 +141,12 @@ Kaui::Engine.routes.draw do
141
141
  post '/:id/tags' => 'subscriptions#update_tags', :as => 'update_subscriptions_tags'
142
142
  get '/:id/edit_bcd' => 'subscriptions#edit_bcd', :as => 'edit_bcd'
143
143
  put '/:id/update_bcd' => 'subscriptions#update_bcd', :as => 'update_bcd'
144
+ get '/:id/edit_quantity' => 'subscriptions#edit_quantity', :as => 'edit_quantity'
145
+ put '/:id/update_quantity' => 'subscriptions#update_quantity', :as => 'update_quantity'
146
+ get '/:id/record_usage' => 'subscriptions#record_usage', :as => 'record_usage'
147
+ post '/:id/record_usage' => 'subscriptions#create_usage', :as => 'create_usage'
144
148
  put '/:id/reinstate' => 'subscriptions#reinstate', :as => 'reinstate'
149
+ get '/:id/show_json' => 'subscriptions#show_json', :as => 'show_subscription_json'
145
150
  get '/validate_external_key' => 'subscriptions#validate_external_key', :as => 'subscriptions_validate_external_key'
146
151
  get '/validate_bundle_external_key' => 'subscriptions#validate_bundle_external_key', :as => 'subscriptions_validate_bundle_external_key'
147
152
  end
data/lib/kaui/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kaui
4
- VERSION = '4.0.17'
4
+ VERSION = '4.0.19'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kaui
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.17
4
+ version: 4.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kill Bill core team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -507,6 +507,7 @@ files:
507
507
  - app/models/kaui/tag_definition.rb
508
508
  - app/models/kaui/tenant.rb
509
509
  - app/models/kaui/transaction.rb
510
+ - app/models/kaui/usage.rb
510
511
  - app/models/kaui/user.rb
511
512
  - app/models/kaui/user_role.rb
512
513
  - app/services/dependencies/kenui.rb
@@ -649,9 +650,12 @@ files:
649
650
  - app/views/kaui/subscriptions/_edit_form.html.erb
650
651
  - app/views/kaui/subscriptions/_form.html.erb
651
652
  - app/views/kaui/subscriptions/_subscriptions_table.html.erb
653
+ - app/views/kaui/subscriptions/_view_json_modal.html.erb
652
654
  - app/views/kaui/subscriptions/edit.html.erb
653
655
  - app/views/kaui/subscriptions/edit_bcd.erb
656
+ - app/views/kaui/subscriptions/edit_quantity.erb
654
657
  - app/views/kaui/subscriptions/new.html.erb
658
+ - app/views/kaui/subscriptions/record_usage.erb
655
659
  - app/views/kaui/tag_definitions/_form.html.erb
656
660
  - app/views/kaui/tag_definitions/index.html.erb
657
661
  - app/views/kaui/tag_definitions/new.html.erb