kaui 4.0.21 → 4.0.24

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: bedd9b4f949e19f4818536fb8374a3adfeab4b6adbb87d6e6fd146ee2e19c82a
4
- data.tar.gz: 658d3fb91736468eb634d4ac3f54c1710d30717293b5d3ff8e2633f08c71df8a
3
+ metadata.gz: 19115a4ca850dfd755598a560dc66e96b00694f00b305e057260fd6b13c57e3d
4
+ data.tar.gz: 9780699cd5e97d8c6887b3e0c200d968a11e644afd5496c58829277d558a28bc
5
5
  SHA512:
6
- metadata.gz: 3a79003af9a6324459d100f84fd14717e4a432e26c58e201472d7e35a5ee693a6943ecbd6c423c2dffe86cdfd258f23008711f934d254f2babb82fe3e5cbadb2
7
- data.tar.gz: 6cf9a83025b46de823588ff1783b7dd0c336f8e3ecfdaaf7888af336af706a55f6b033a04a1f736690466f9b09bd36b0239ee8cfd8170754dcd342343f0039e9
6
+ metadata.gz: 8bcf2e6e322833277c4d305aca8621bf4495cc6443961af0a565bf7da0d6efdb5892d8b66ac31e820f08953d221ad54b309018fbd3d088032e211ea73d51abd9
7
+ data.tar.gz: e1a44d558e7b996185be6a79cca68fb2eecb4e200f2affff7e5de3ccf0ca6a38f78d0ffdc814c67f3ab794585e6d7b4c4604dfe06ea77e7b7bc86360e229273e
@@ -29,8 +29,8 @@ function searchParseOperatorText(text) {
29
29
  // Function to parse URL parameters
30
30
  function getUrlParams() {
31
31
  var params = {};
32
- var queryString = window.location.search.substring(1);
33
- queryString = queryString.replace(/ac_id/g, 'account_id');
32
+ var advanceSearchQuery = new URLSearchParams(window.location.search).get('advance_search_query');
33
+ var queryString = advanceSearchQuery !== null ? advanceSearchQuery : window.location.search.substring(1).replace(/ac_id/g, 'account_id');
34
34
  var regex = /([^&=]+)=([^&]*)/g;
35
35
  var m;
36
36
  while (m = regex.exec(queryString)) {
@@ -55,15 +55,17 @@ function populateSearchLabelsFromUrl() {
55
55
  if (match) {
56
56
  var columnName = match[1].replace(/_/g, ' ').replace(/^\w/, function(l) { return l.toUpperCase(); });
57
57
  var filter = searchFormatOperator(match[2].trim());
58
+ var isHiddenAccountFilter = match[1].trim().toLowerCase() === 'account_id' && window.location.pathname.includes('/accounts/');
58
59
  var label = $('<span>', {
59
- class: 'label label-info d-inline-flex align-items-center gap-2',
60
+ class: 'label label-info d-inline-flex align-items-center gap-2' + (isHiddenAccountFilter ? ' d-none' : ''),
60
61
  'data-field': columnName.trim(),
61
62
  'data-filter': filter.trim(),
62
- 'data-value': value.trim()
63
+ 'data-value': value.trim(),
64
+ 'data-hidden-filter': isHiddenAccountFilter
63
65
  });
64
66
 
65
67
  if (hasBalanceFilter && columnName.toLowerCase().trim() !== 'balance') {
66
- label.attr('class', 'label label-default d-inline-flex align-items-center gap-2');
68
+ label.attr('class', 'label label-default d-inline-flex align-items-center gap-2' + (isHiddenAccountFilter ? ' d-none' : ''));
67
69
  }
68
70
 
69
71
  var labelText = $('<span>', {
@@ -168,6 +170,10 @@ function showAdvanceSearchModal() {
168
170
 
169
171
  // Populate the search fields with the current filters
170
172
  searchLabelsContainer.find('.label').each(function() {
173
+ if ($(this).data('hidden-filter')) {
174
+ return;
175
+ }
176
+
171
177
  var labelText = $(this).text().trim();
172
178
  var parts = labelText.split(' [');
173
179
  var columnName = parts[0].trim();
@@ -226,6 +232,10 @@ $(document).on('click', '.filter-close-icon', function() {
226
232
  }
227
233
  });
228
234
  var searchParams = searchQuery();
235
+ // searchQuery() returns an already percent-encoded field[op]=value fragment; decode it
236
+ // back to raw text so it can be sent as the (single-encoded) value of the dedicated
237
+ // advance_search_query param instead of as top-level bracketed keys.
238
+ var rawSearchParams = searchParams ? decodeURIComponent(searchParams) : searchParams;
229
239
  // Reapply search without the removed filter
230
240
  var tableSelectors = ['#invoices-table', '#accounts-table', '#payments-table', '#subscriptions-table'];
231
241
  var table = null;
@@ -240,15 +250,14 @@ $(document).on('click', '.filter-close-icon', function() {
240
250
 
241
251
  if (table && table.ajax) {
242
252
  table.on('preXhr.dt', function(e, settings, data) {
243
- data.search.value = searchParams;
253
+ data.advance_search_query = rawSearchParams;
244
254
  });
245
255
  table.ajax.reload(null, false);
246
256
  }
247
257
 
248
258
  // Update URL
249
- if (searchParams) {
250
- var pushParams = (searchParams || '').replace(/account_id/g, 'ac_id');
251
- var newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?' + pushParams;
259
+ if (rawSearchParams) {
260
+ var newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?advance_search_query=' + encodeURIComponent(rawSearchParams);
252
261
  window.history.pushState({ path: newUrl }, '', newUrl);
253
262
  } else {
254
263
  var newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
@@ -641,6 +641,59 @@
641
641
  color: #414651;
642
642
  }
643
643
 
644
+ /* app/views/kaui/admin_tenants/_form_invoice_translation.erb */
645
+
646
+ .existing-invoice-translations-container {
647
+ max-width: 80rem;
648
+ width: 100%;
649
+ }
650
+
651
+ .existing-invoice-translations-container .existing-invoice-translations-header {
652
+ display: flex;
653
+ align-items: start;
654
+ justify-content: space-between;
655
+ padding: 0;
656
+ border-bottom: 0.0625rem solid #E9EAEB;
657
+ }
658
+
659
+ .existing-invoice-translations-container .existing-invoice-translations-header h2 {
660
+ font-weight: 600;
661
+ font-size: 1.125rem;
662
+ line-height: 1.75rem;
663
+ color: #414651;
664
+ margin-bottom: 1.25rem;
665
+ }
666
+
667
+ .existing-invoice-translations-container table.existing-invoice-translations-table {
668
+ overflow-x: auto;
669
+ min-width: 100%;
670
+ border: none !important;
671
+ }
672
+
673
+ .existing-invoice-translations-container table.existing-invoice-translations-table tbody tr {
674
+ border-bottom: 0.0625rem solid #E9EAEB !important;
675
+ }
676
+
677
+ .existing-invoice-translations-container table.existing-invoice-translations-table th {
678
+ padding: 0.375rem 0.75rem !important;
679
+ font-weight: 500 !important;
680
+ font-size: 0.75rem !important;
681
+ line-height: 1.125rem;
682
+ letter-spacing: 0;
683
+ color: #717680;
684
+ text-transform: capitalize;
685
+ background-color: #FAFAFA !important;
686
+ }
687
+
688
+ .existing-invoice-translations-container table.existing-invoice-translations-table td {
689
+ font-weight: 500 !important;
690
+ font-size: 0.875rem !important;
691
+ line-height: 1.125rem !important;
692
+ text-align: start;
693
+ padding: 0.3125rem 0.75rem !important;
694
+ color: #535862 !important;
695
+ }
696
+
644
697
  /* app/views/kaui/admin_tenants/_show_catalog_simple.erb */
645
698
 
646
699
  #catalog_simple, #catalog_xml {
@@ -53,6 +53,12 @@ module Kaui
53
53
  @invoice_template = nil
54
54
  end
55
55
 
56
+ fetch_invoice_translations = promise do
57
+ Kaui::AdminTenant.get_invoice_translations(options)
58
+ rescue StandardError
59
+ @invoice_translations = {}
60
+ end
61
+
56
62
  fetch_tenant_plugin_config = promise { Kaui::AdminTenant.get_tenant_plugin_config(options) }
57
63
 
58
64
  @catalog_versions = []
@@ -76,6 +82,12 @@ module Kaui
76
82
  nil
77
83
  end
78
84
 
85
+ @invoice_translations = begin
86
+ wait(fetch_invoice_translations)
87
+ rescue StandardError
88
+ {}
89
+ end
90
+
79
91
  @tenant_plugin_config = begin
80
92
  wait(fetch_tenant_plugin_config)
81
93
  rescue StandardError
@@ -181,7 +181,12 @@ module Kaui
181
181
 
182
182
  def record_usage
183
183
  @subscription = Kaui::Subscription.find_by_id(params.require(:id), 'NONE', options_for_klient)
184
- @unit_types = fetch_unit_types_from_subscription(@subscription)
184
+ @is_aviate_catalog = Dependencies::Aviate::Metering.aviate_catalog?(@subscription.account_id, options_for_aviate_klient)
185
+ @unit_types = if @is_aviate_catalog
186
+ Dependencies::Aviate::Metering.billing_meter_codes_for_plan(@subscription.plan_name, @subscription.account_id, options_for_aviate_klient)
187
+ else
188
+ fetch_unit_types_from_subscription(@subscription)
189
+ end
185
190
  end
186
191
 
187
192
  def create_usage
@@ -189,56 +194,65 @@ module Kaui
189
194
  unit_type = params[:unit_type].to_s.strip
190
195
  amount_raw = params[:amount].to_s.strip
191
196
  record_date = params[:record_date].to_s.strip
197
+ tracking_id = params[:tracking_id].to_s.strip
198
+
199
+ @subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', options_for_klient)
200
+ @is_aviate_catalog = Dependencies::Aviate::Metering.aviate_catalog?(@subscription.account_id, options_for_aviate_klient)
192
201
 
193
202
  # Input validation
194
203
  errors = []
195
- errors << 'Unit type is required' if unit_type.blank?
204
+ errors << (@is_aviate_catalog ? 'Billing meter code is required' : 'Unit type is required') if unit_type.blank?
196
205
  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
206
+ amount = @is_aviate_catalog ? parse_usage_amount(amount_raw) : Integer(amount_raw, exception: false)
207
+ errors << (@is_aviate_catalog ? 'Amount must be a positive number' : 'Amount must be a positive integer') if amount.nil? || amount <= 0
199
208
  errors << 'Date/time of usage is required' if record_date.blank?
200
209
  parsed_date = parse_usage_date(record_date) if record_date.present?
201
210
  errors << 'Date/time of usage must be a valid date or datetime' if record_date.present? && parsed_date.nil?
211
+ errors << 'Tracking ID must be a valid UUID' if @is_aviate_catalog && tracking_id.present? && !tracking_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
202
212
 
203
213
  if errors.any?
204
214
  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)
215
+ @unit_types = fetch_unit_types_for_record_usage_form(@subscription, @is_aviate_catalog)
207
216
  @unit_type = unit_type
208
217
  @amount = amount_raw
209
218
  @record_date = record_date
210
- @tracking_id = params[:tracking_id]
219
+ @tracking_id = tracking_id
211
220
  render :record_usage and return
212
221
  end
213
222
 
214
223
  begin
215
- usage_record = KillBillClient::Model::UsageRecordAttributes.new
216
- usage_record.record_date = parsed_date.utc.iso8601
217
- usage_record.amount = amount
224
+ if @is_aviate_catalog
225
+ # trackingId is required by the Aviate metering API; auto-generate one if the user left it blank
226
+ tracking_id = SecureRandom.uuid if tracking_id.blank?
227
+ Dependencies::Aviate::Metering.submit_usage_event(@subscription.account_id, unit_type, subscription_id, tracking_id,
228
+ parsed_date.utc.strftime('%Y-%m-%dT%H:%M:%S'), amount.to_f, options_for_aviate_klient)
229
+ else
230
+ usage_record = KillBillClient::Model::UsageRecordAttributes.new
231
+ usage_record.record_date = parsed_date.utc.iso8601
232
+ usage_record.amount = amount.to_i
218
233
 
219
- unit_usage_record = KillBillClient::Model::UnitUsageRecordAttributes.new
220
- unit_usage_record.unit_type = unit_type
221
- unit_usage_record.usage_records = [usage_record]
234
+ unit_usage_record = KillBillClient::Model::UnitUsageRecordAttributes.new
235
+ unit_usage_record.unit_type = unit_type
236
+ unit_usage_record.usage_records = [usage_record]
222
237
 
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]
238
+ usage = Kaui::Usage.new
239
+ usage.subscription_id = subscription_id
240
+ usage.tracking_id = tracking_id.presence
241
+ usage.unit_usage_records = [unit_usage_record]
227
242
 
228
- usage.create(current_user.kb_username, nil, nil, options_for_klient)
243
+ usage.create(current_user.kb_username, nil, nil, options_for_klient)
244
+ end
229
245
 
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'
246
+ redirect_to kaui_engine.account_bundles_path(@subscription.account_id), notice: 'Usage was successfully recorded'
232
247
  rescue StandardError => e
233
248
  Rails.logger.error("Failed to record usage for subscription #{subscription_id}: #{e.class}: #{e.message}")
234
249
  Rails.logger.error(e.backtrace.join("\n")) if e.backtrace
235
250
  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)
251
+ @unit_types = fetch_unit_types_for_record_usage_form(@subscription, @is_aviate_catalog)
238
252
  @unit_type = unit_type
239
253
  @amount = amount_raw
240
254
  @record_date = record_date
241
- @tracking_id = params[:tracking_id]
255
+ @tracking_id = tracking_id
242
256
  render :record_usage
243
257
  end
244
258
  end
@@ -411,6 +425,27 @@ module Kaui
411
425
  (phase.prices || []).empty?
412
426
  end
413
427
 
428
+ def fetch_unit_types_for_record_usage_form(subscription, is_aviate_catalog)
429
+ if is_aviate_catalog
430
+ Dependencies::Aviate::Metering.billing_meter_codes_for_plan(subscription.plan_name, subscription.account_id, options_for_aviate_klient)
431
+ else
432
+ fetch_unit_types_from_subscription(subscription)
433
+ end
434
+ end
435
+
436
+ # The Aviate plugin's catalog/metering endpoints require a JWT (obtained by logging in via the
437
+ # Aviate Configuration page in killbill-aviate-ui), stored in a jwt_token cookie - same convention
438
+ # as Aviate::EngineController#options_for_klient in that engine.
439
+ def options_for_aviate_klient
440
+ options_for_klient(jwt_token: cookies[:jwt_token])
441
+ end
442
+
443
+ def parse_usage_amount(amount_raw)
444
+ BigDecimal(amount_raw, exception: false)
445
+ rescue ArgumentError
446
+ nil
447
+ end
448
+
414
449
  def fetch_unit_types_from_subscription(subscription)
415
450
  unit_types = []
416
451
  (subscription.prices || []).each do |phase_price|
@@ -23,6 +23,16 @@ module Kaui
23
23
  KillBillClient::Model::Invoice.upload_invoice_translation(invoice_translation, locale, delete_if_exists, user, reason, comment, options)
24
24
  end
25
25
 
26
+ # Return a map of locale => invoice translation content, for every invoice translation uploaded for the tenant
27
+ def get_invoice_translations(options = {})
28
+ raw_translations = KillBillClient::Model::Tenant.search_tenant_config('INVOICE_TRANSLATION_', options)
29
+
30
+ raw_translations.each_with_object({}) do |e, hsh|
31
+ locale = e.key.gsub('INVOICE_TRANSLATION_', '')
32
+ hsh[locale] = e.values[0]
33
+ end
34
+ end
35
+
26
36
  def upload_catalog_translation(catalog_translation, locale, delete_if_exists, user = nil, reason = nil, comment = nil, options = {})
27
37
  KillBillClient::Model::Invoice.upload_catalog_translation(catalog_translation, locale, delete_if_exists, user, reason, comment, options)
28
38
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dependencies
4
+ module Aviate
5
+ # Thin wrapper around the Aviate plugin's Catalog and Metering REST APIs
6
+ # (https://apidocs.killbill.io/aviate-catalog.html, https://apidocs.killbill.io/aviate-metering.html).
7
+ # These are not (yet) exposed by the killbill-client gem, so we call them directly.
8
+ #
9
+ # Unlike most Kill Bill APIs, these endpoints do NOT accept tenant RBAC (username/password) or
10
+ # api_key/api_secret alone - they require a JWT obtained via the Aviate Auth API
11
+ # (https://apidocs.killbill.io/aviate-auth.html), passed as a `jwt_token` entry in options_for_klient
12
+ # (see Aviate::EngineController#options_for_klient in killbill-aviate-ui, which stores it in a
13
+ # `jwt_token` cookie after logging in via the Aviate Configuration page). If no jwt_token is present,
14
+ # calls will fail authentication and callers should treat that as "unknown/not Aviate".
15
+ class Metering
16
+ KILLBILL_AVIATE_PREFIX = '/plugins/aviate-plugin/v1'
17
+ CATALOG_STATE_AVIATE = 'CATALOG_STATE_AVIATE'
18
+
19
+ class << self
20
+ # Returns true if the tenant/account is using an Aviate catalog (as opposed to an XML one).
21
+ # Any error (plugin not installed, missing/invalid JWT, network issue, etc.) is treated as
22
+ # "not Aviate" so that callers can safely fall back to the existing XML-catalog behavior.
23
+ def aviate_catalog?(account_id, options_for_klient)
24
+ response = KillBillClient::API.get("#{KILLBILL_AVIATE_PREFIX}/catalog/info", catalog_params(account_id), request_options(options_for_klient))
25
+ JSON.parse(response.body)['state'] == CATALOG_STATE_AVIATE
26
+ rescue StandardError => e
27
+ Rails.logger.warn("Failed to retrieve Aviate catalog info: #{e.class}: #{e.message}")
28
+ false
29
+ end
30
+
31
+ # Returns the list of billing meter codes configured on the given plan's usage phases.
32
+ def billing_meter_codes_for_plan(plan_name, account_id, options_for_klient)
33
+ return [] if plan_name.blank?
34
+
35
+ response = KillBillClient::API.get("#{KILLBILL_AVIATE_PREFIX}/catalog/#{plan_name}/plan", catalog_params(account_id), request_options(options_for_klient))
36
+ plan = JSON.parse(response.body)
37
+
38
+ codes = []
39
+ Array(plan['phases']).each do |phase|
40
+ Array(phase['usages']).each do |usage|
41
+ Array(usage['tiers']).each do |tier|
42
+ Array(tier['blocks']).each do |block|
43
+ codes << block['billingMeterCode'] if block['billingMeterCode'].present?
44
+ end
45
+ end
46
+ end
47
+ end
48
+ codes.uniq
49
+ rescue StandardError => e
50
+ Rails.logger.warn("Failed to retrieve Aviate billing meter codes for plan #{plan_name}: #{e.class}: #{e.message}")
51
+ []
52
+ end
53
+
54
+ # Submits a single usage event via the Aviate metering plugin.
55
+ def submit_usage_event(account_id, billing_meter_code, subscription_id, tracking_id, timestamp, value, options_for_klient)
56
+ body = [{
57
+ billingMeterCode: billing_meter_code,
58
+ subscriptionId: subscription_id,
59
+ trackingId: tracking_id,
60
+ timestamp: timestamp,
61
+ value: value
62
+ }].to_json
63
+
64
+ KillBillClient::API.post("#{KILLBILL_AVIATE_PREFIX}/metering/billing/#{account_id}", body, {}, request_options(options_for_klient))
65
+ end
66
+
67
+ private
68
+
69
+ def catalog_params(account_id)
70
+ account_id.present? ? { accountId: account_id } : {}
71
+ end
72
+
73
+ # Builds request options scoped to just api_key/api_secret + the Aviate JWT bearer header.
74
+ # Deliberately excludes username/password/session_id from options_for_klient: KillBillClient's
75
+ # net_http_adapter calls request.basic_auth(username, password) when they're present, which
76
+ # would overwrite (clobber) the Authorization header we set below for the Bearer token.
77
+ def request_options(options_for_klient)
78
+ request_options = {
79
+ api_key: options_for_klient[:api_key],
80
+ api_secret: options_for_klient[:api_secret]
81
+ }
82
+ jwt_token = options_for_klient[:jwt_token]
83
+ request_options[:head] = { 'Authorization' => "Bearer #{jwt_token}" } if jwt_token.present?
84
+ request_options
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -63,7 +63,7 @@
63
63
  $(document).ready(function() {
64
64
  var tags = JSON.parse($("#tags").val());
65
65
  var table = $('#tags-table').DataTable({
66
- "dom": "<'row'<'col-md-6'l><'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
66
+ "dom": "<'row'<'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
67
67
  data: tags
68
68
  });
69
69
 
@@ -132,11 +132,10 @@ $(document).ready(function() {
132
132
  // If the page loaded with advanced search params in the URL (e.g. restored from
133
133
  // localStorage) but @advance_search_query was not set server-side, re-fire the
134
134
  // DataTable request with the correct filters.
135
- if (window.location.search.includes('_q=1') && '<%= j(@advance_search_query.to_s.strip) %>' === '') {
136
- var urlSearch = window.location.search.substring(1).replace(/ac_id/g, 'account_id');
135
+ if (window.location.search.includes('advance_search_query=') && '<%= j(@advance_search_query.to_s.strip) %>' === '') {
136
+ var urlSearch = new URLSearchParams(window.location.search).get('advance_search_query') || '';
137
137
  var ajaxUrl = "<%= accounts_pagination_path(:ordering => @ordering, :format => :json) %>";
138
138
  table.on('preXhr.dt.filter', function(e, settings, data) {
139
- data.search.value = urlSearch;
140
139
  data.advance_search_query = urlSearch;
141
140
  });
142
141
  table.ajax.url(ajaxUrl).load();
@@ -2,7 +2,6 @@
2
2
  <div class="tab-pane fade invoice-template form-invoice-template" id="InvoiceTemplate">
3
3
  <% if @invoice_template.present? %>
4
4
  <div class="invoice-template-display mb-4">
5
- <h5>Current Invoice Template</h5>
6
5
  <div class="card">
7
6
  <div class="card-body p-0">
8
7
  <iframe src="<%= admin_tenant_invoice_template_path(@tenant.id) %>" style="width: 100%; height: 500px; border: none;" title="Invoice Template Preview"></iframe>
@@ -1,5 +1,51 @@
1
1
  <% if can? :config_upload, Kaui::AdminTenant %>
2
2
  <div class="tab-pane fade" id="InvoiceTranslation">
3
+ <div class="existing-invoice-translations-container mb-4">
4
+ <div class="d-flex flex-column">
5
+ <div class="existing-invoice-translations-header mb-3">
6
+ <div class="d-flex align-items-start flex-column">
7
+ </div>
8
+ </div>
9
+ <div>
10
+ <table id="existing-invoice-translations-for-tenants" class="existing-invoice-translations-table">
11
+ <% if @invoice_translations.blank? %>
12
+ <tbody><tr><td>No invoice translation defined for tenant</td></tr></tbody>
13
+ <% else %>
14
+ <thead>
15
+ <tr>
16
+ <th>Locale</th>
17
+ <th></th>
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ <% @invoice_translations.each do |locale, content| %>
22
+ <tr>
23
+ <td><%= locale %></td>
24
+ <td class="text-end">
25
+ <%= render "kaui/components/button/button", {
26
+ label: 'View Source',
27
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
28
+ type: "button",
29
+ html_class: "kaui-button custom-hover",
30
+ html_options: {
31
+ onclick: '$(this).blur(); var $row = $(this).closest("tr").next(".invoice-translation-source-row"); $row.toggle(); $(this).find(".kaui-button-label").text($row.is(":visible") ? "Hide Source" : "View Source"); return false;'
32
+ }
33
+ } %>
34
+ </td>
35
+ </tr>
36
+ <tr class="invoice-translation-source-row" style="display: none;">
37
+ <td colspan="2">
38
+ <pre class="mb-0 p-2 bg-light" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; font-family: monospace; font-size: 12px;"><%= html_escape(content) %></pre>
39
+ </td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ <% end %>
44
+ </table>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
3
49
  <%= form_tag({:action => :upload_invoice_translation}, :method => 'post', :multipart => true, :class => 'form-horizontal') do %>
4
50
  <%= hidden_field_tag(:id, @tenant.id) %>
5
51
 
@@ -21,7 +67,7 @@
21
67
  <div class="form-group d-flex pb-3 border-bottom mb-3">
22
68
  <%= label_tag :translation_locale, 'Locale', :class => 'col-sm-2 control-label' %>
23
69
  <div class="col-sm-10">
24
- <%= text_field_tag :translation_locale, nil, :class => 'form-control', :required => true, :placeholder => 'e.g. en_US' %>
70
+ <%= select_tag :translation_locale, options_for_select([['-- Select a locale --', '']] + all_available_locales, { disabled: ['---------------'] }), class: 'form-control', required: true %>
25
71
  </div>
26
72
  </div>
27
73
  <div class="d-flex justify-content-end">
@@ -103,7 +103,7 @@
103
103
  $(document).ready(function() {
104
104
  var auditLogs = JSON.parse($("#audit-logs").val());
105
105
  var table = $('#audit-logs-table').DataTable({
106
- dom: "<'row'<'col-md-6'l><'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
106
+ "dom": "<'row'<'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
107
107
  pagingType: "full_numbers",
108
108
  data: auditLogs,
109
109
  "scrollX": true,
@@ -9,7 +9,7 @@
9
9
  <% icon_html = icon.present? ? image_tag(icon, class: "me-1", size: "16x16") : "" %>
10
10
  <% trailing_icon_html = trailing_icon.present? ? image_tag(trailing_icon, class: "ms-1", size: "16x16") : "" %>
11
11
 
12
- <%= tag.button raw("#{icon_html}#{label}#{trailing_icon_html}"),
12
+ <%= tag.button raw("#{icon_html}<span class=\"kaui-button-label\">#{label}</span>#{trailing_icon_html}"),
13
13
  type: type,
14
14
  class: "btn #{variant} #{html_class}",
15
15
  **html_options
@@ -82,7 +82,7 @@
82
82
  <%= javascript_tag do %>
83
83
  $(document).ready(function() {
84
84
  var table = $('#custom_fields-table').DataTable({
85
- "dom": "<'row'<'col-md-6'l><'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
85
+ "dom": "<'row'<'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
86
86
  "pagingType": <% if @max_nb_records.nil? -%>"simple"<% else -%>"full_numbers"<% end -%>,
87
87
  "language": {
88
88
  <!-- See DefaultPaginationSqlDaoHelper.java -->
@@ -100,18 +100,17 @@ $(document).ready(function() {
100
100
  });
101
101
  return reorderedData;
102
102
  }
103
- },
104
- "columns": <%= raw @visible_columns.to_json %>
103
+ }
105
104
  });
106
105
 
107
106
  // If the page loaded with advanced search params in the URL (e.g. restored from
108
107
  // localStorage) but @advance_search_query was not set server-side, re-fire the
109
108
  // DataTable request with the correct filters.
110
- if (window.location.search.includes('_q=1') && '<%= j(@advance_search_query.to_s.strip) %>' === '' && !window.location.pathname.includes('/accounts/')) {
111
- var urlSearch = window.location.search.substring(1).replace(/ac_id/g, 'account_id');
112
- var ajaxUrl = "<%= invoices_pagination_path(:ordering => @ordering, :format => :json) %>" + '?' + urlSearch;
109
+ if (window.location.search.includes('advance_search_query=') && '<%= j(@advance_search_query.to_s.strip) %>' === '' && !window.location.pathname.includes('/accounts/')) {
110
+ var urlSearch = new URLSearchParams(window.location.search).get('advance_search_query') || '';
111
+ var ajaxUrl = "<%= invoices_pagination_path(:ordering => @ordering, :format => :json) %>" + '?advance_search_query=' + encodeURIComponent(urlSearch);
113
112
  table.on('preXhr.dt.filter', function(e, settings, data) {
114
- data.search.value = urlSearch;
113
+ data.advance_search_query = urlSearch;
115
114
  });
116
115
  table.ajax.url(ajaxUrl).load();
117
116
  }
@@ -115,18 +115,17 @@ $(document).ready(function() {
115
115
  <% end %>
116
116
  "processing": true,
117
117
  "serverSide": true,
118
- "search": {"search": "<%= @search_query %>"},
119
- "columns": <%= raw @visible_columns.to_json %>
118
+ "search": {"search": "<%= @search_query %>"}
120
119
  });
121
120
 
122
121
  // If the page loaded with advanced search params in the URL (e.g. restored from
123
122
  // localStorage) but @advance_search_query was not set server-side, re-fire the
124
123
  // DataTable request with the correct filters.
125
- if (window.location.search.includes('_q=1') && '<%= j(@advance_search_query.to_s.strip) %>' === '' && !window.location.pathname.includes('/accounts/')) {
126
- var urlSearch = window.location.search.substring(1).replace(/ac_id/g, 'account_id');
127
- var ajaxUrl = "<%= payments_pagination_path(:ordering => @ordering, :format => :json) %>" + '?' + urlSearch;
124
+ if (window.location.search.includes('advance_search_query=') && '<%= j(@advance_search_query.to_s.strip) %>' === '' && !window.location.pathname.includes('/accounts/')) {
125
+ var urlSearch = new URLSearchParams(window.location.search).get('advance_search_query') || '';
126
+ var ajaxUrl = "<%= payments_pagination_path(:ordering => @ordering, :format => :json) %>" + '?advance_search_query=' + encodeURIComponent(urlSearch);
128
127
  table.on('preXhr.dt.filter', function(e, settings, data) {
129
- data.search.value = urlSearch;
128
+ data.advance_search_query = urlSearch;
130
129
  });
131
130
  table.ajax.url(ajaxUrl).load();
132
131
  }
@@ -191,19 +191,19 @@ $(document).ready(function() {
191
191
  var table = $('<%= table_selector %>').DataTable();
192
192
  table.off('preXhr.dt.filter');
193
193
  table.on('preXhr.dt.filter', function(e, settings, data) {
194
- data.search.value = searchQuery('<%= j search_query.to_s %>');
194
+ data.advance_search_query = decodeURIComponent(searchQuery('<%= j search_query.to_s %>'));
195
195
  });
196
196
 
197
197
  var searchParams = searchQuery('<%= j search_query.to_s %>');
198
+ var rawSearchParams = searchParams ? decodeURIComponent(searchParams) : searchParams;
198
199
  var ajaxUrl = "<%= pagination_path %>";
199
- if (searchParams) {
200
- ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + searchParams;
200
+ if (rawSearchParams) {
201
+ ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + 'advance_search_query=' + encodeURIComponent(rawSearchParams);
201
202
  }
202
203
  table.ajax.url(ajaxUrl).load();
203
204
 
204
- if (searchParams) {
205
- searchParams = searchParams.replace(/account_id/g, 'ac_id');
206
- var newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?' + searchParams;
205
+ if (rawSearchParams) {
206
+ var newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + '?advance_search_query=' + encodeURIComponent(rawSearchParams);
207
207
  window.history.pushState({ path: newUrl }, '', newUrl);
208
208
  }
209
209
 
@@ -22,22 +22,34 @@
22
22
  <%= form_tag create_usage_path(@subscription.subscription_id), method: :post, id: 'record_usage_form', class: 'form-horizontal' do %>
23
23
 
24
24
  <div class="form-group d-flex pb-3">
25
- <%= label_tag :unit_type, 'Unit Type', class: 'col-sm-3 control-label' %>
25
+ <%= label_tag :unit_type, (@is_aviate_catalog ? 'Billing Meter Code' : 'Unit Type'), class: 'col-sm-3 control-label' %>
26
26
  <div class="col-sm-9">
27
27
  <% if @unit_types.length == 1 %>
28
28
  <%= text_field_tag :unit_type, @unit_types.first, readonly: true, required: true, class: 'form-control bg-light' %>
29
29
  <% elsif @unit_types.length > 1 %>
30
30
  <%= select_tag :unit_type, options_for_select(@unit_types.map { |u| [u, u] }, @unit_type.presence || @unit_types.first), required: true, class: 'form-control', title: (@unit_type.presence || @unit_types.first) %>
31
31
  <% else %>
32
- <%= text_field_tag :unit_type, @unit_type, required: true, maxlength: 255, class: 'form-control', placeholder: 'e.g. api-calls' %>
32
+ <%= text_field_tag :unit_type, @unit_type, required: true, maxlength: 255, class: 'form-control', placeholder: (@is_aviate_catalog ? 'e.g. api-calls-meter' : 'e.g. api-calls') %>
33
33
  <% end %>
34
+ <small class="form-text text-muted">
35
+ <% if @is_aviate_catalog %>
36
+ This subscription's plan is billed via an Aviate catalog: usage is recorded against a <strong>billing meter code</strong> instead of a unit type.
37
+ <% else %>
38
+ This subscription's plan is billed via a standard (XML) catalog: usage is recorded against a <strong>unit type</strong>.
39
+ <% end %>
40
+ <a href="#" data-toggle="tooltip" class="kb-tooltip" title="Kaui automatically detects the catalog type for this subscription's account and submits usage through the matching Kill Bill API: the Record Usage API (unit type) for standard catalogs, or the Aviate Submit Usage Events API (billing meter code) for Aviate catalogs."></a>
41
+ </small>
34
42
  </div>
35
43
  </div>
36
44
 
37
45
  <div class="form-group d-flex pb-3">
38
46
  <%= label_tag :amount, 'Amount', class: 'col-sm-3 control-label' %>
39
47
  <div class="col-sm-9">
40
- <%= number_field_tag :amount, @amount, required: true, min: 1, step: 1, class: 'form-control', placeholder: 'Positive integer' %>
48
+ <% if @is_aviate_catalog %>
49
+ <%= number_field_tag :amount, @amount, required: true, min: 0, step: 'any', class: 'form-control', placeholder: 'Positive number' %>
50
+ <% else %>
51
+ <%= number_field_tag :amount, @amount, required: true, min: 1, step: 1, class: 'form-control', placeholder: 'Positive integer' %>
52
+ <% end %>
41
53
  </div>
42
54
  </div>
43
55
 
@@ -84,10 +96,12 @@
84
96
  <div class="form-group d-flex pb-3">
85
97
  <%= label_tag :tracking_id, 'Tracking ID', class: 'col-sm-3 control-label' %>
86
98
  <div class="col-sm-9">
87
- <%= text_field_tag :tracking_id, @tracking_id, maxlength: 255, class: 'form-control', placeholder: 'Optional, used for idempotency' %>
99
+ <%= text_field_tag :tracking_id, @tracking_id, maxlength: 255, class: 'form-control', placeholder: (@is_aviate_catalog ? 'Optional UUID, auto-generated if left blank' : 'Optional, used for idempotency') %>
88
100
  </div>
89
101
  </div>
90
102
 
103
+ <%= hidden_field_tag :is_aviate_catalog, @is_aviate_catalog.to_s %>
104
+
91
105
  <div class="form-group d-flex justify-content-end pb-3">
92
106
  <%= render "kaui/components/button/button", {
93
107
  label: 'Close',
@@ -113,8 +127,11 @@
113
127
 
114
128
  <%= javascript_tag do %>
115
129
  document.addEventListener('DOMContentLoaded', function () {
130
+ $('[data-toggle="tooltip"]').tooltip();
131
+
116
132
  var form = document.getElementById('record_usage_form');
117
133
  if (!form) { return; }
134
+ var isAviateCatalog = document.getElementById('is_aviate_catalog') && document.getElementById('is_aviate_catalog').value === 'true';
118
135
  var unitTypeSelect = document.getElementById('unit_type');
119
136
  if (unitTypeSelect && unitTypeSelect.tagName === 'SELECT') {
120
137
  unitTypeSelect.title = unitTypeSelect.value || '';
@@ -129,13 +146,20 @@
129
146
  var unitTypeEl = document.getElementById('unit_type');
130
147
  var unitType = unitTypeEl ? (unitTypeEl.value || '').trim() : '';
131
148
  if (unitType.length === 0) {
132
- errors.push('Unit type is required.');
149
+ errors.push(isAviateCatalog ? 'Billing meter code is required.' : 'Unit type is required.');
133
150
  }
134
151
 
135
152
  var amountStr = (document.getElementById('amount').value || '').trim();
136
- var amount = parseInt(amountStr, 10);
137
- if (amountStr.length === 0 || isNaN(amount) || amount <= 0 || String(amount) !== amountStr) {
138
- errors.push('Amount must be a positive integer.');
153
+ if (isAviateCatalog) {
154
+ var floatAmount = parseFloat(amountStr);
155
+ if (amountStr.length === 0 || isNaN(floatAmount) || floatAmount <= 0) {
156
+ errors.push('Amount must be a positive number.');
157
+ }
158
+ } else {
159
+ var amount = parseInt(amountStr, 10);
160
+ if (amountStr.length === 0 || isNaN(amount) || amount <= 0 || String(amount) !== amountStr) {
161
+ errors.push('Amount must be a positive integer.');
162
+ }
139
163
  }
140
164
 
141
165
  var dateEl = document.getElementById('record_date_date');
@@ -71,7 +71,7 @@
71
71
  <%= javascript_tag do %>
72
72
  $(document).ready(function() {
73
73
  var table = $('#tags-table').DataTable({
74
- "dom": "<'row'<'col-md-6'l><'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
74
+ "dom": "<'row'<'col-md-6'f>r>t<'row'<'col-md-6'i><'col-md-6'p>>",
75
75
  "pagingType": <% if @max_nb_records.nil? -%>"simple"<% else -%>"full_numbers"<% end -%>,
76
76
  "language": {
77
77
  "info": <% if @max_nb_records.nil? -%>"Showing _START_ to _END_ of <%= number_with_delimiter(Kaui::EngineControllerUtil::SIMPLE_PAGINATION_THRESHOLD) -%>+ entries"<% else -%>"Showing _START_ to _END_ of _TOTAL_ entries"<% 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.21'
4
+ VERSION = '4.0.24'
5
5
  end
data/lib/kaui.rb CHANGED
@@ -133,6 +133,8 @@ module Kaui
133
133
  view_context.humanized_money_with_symbol(invoice.amount_to_money)
134
134
  when 'balance'
135
135
  view_context.humanized_money_with_symbol(invoice.balance_to_money)
136
+ when 'invoice_number'
137
+ view_context.link_to(invoice.invoice_number, view_context.url_for(controller: :invoices, action: :show, account_id: invoice.account_id, id: invoice.invoice_id))
136
138
  when 'invoice_id'
137
139
  view_context.link_to(invoice.invoice_id, view_context.url_for(controller: :invoices, action: :show, account_id: invoice.account_id, id: invoice.invoice_id))
138
140
  when 'status'
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.21
4
+ version: 4.0.24
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-07-22 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -510,6 +510,7 @@ files:
510
510
  - app/models/kaui/usage.rb
511
511
  - app/models/kaui/user.rb
512
512
  - app/models/kaui/user_role.rb
513
+ - app/services/dependencies/aviate.rb
513
514
  - app/services/dependencies/kenui.rb
514
515
  - app/views/kaui/account_children/index.html.erb
515
516
  - app/views/kaui/account_custom_fields/index.html.erb