killbill-aviate 2.3.0 → 2.4.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8eda43fad137f5751fe32364907cac8c26d9627b009e67e9750415443953504b
4
- data.tar.gz: 9e9a951a294c5035bc926286d6acd51ba2e02fa69664b7ec8baaf7db34d17744
3
+ metadata.gz: '07590f4ddf4251690f20b6c4368c673c834ff9fe7cc1caa7965375073e5be898'
4
+ data.tar.gz: 4ceb836ff7732f47da9c8e7f0836d9ebbdfc034f4e2b09a60556f41ad9e17889
5
5
  SHA512:
6
- metadata.gz: 5a3daf086e3cb0e20ec0fa14f0a92da5c4dda5e71a50916d03ef12add66483c5a5acc0d6c87a7dde4b9e786ec1dfc7b614b884ca184f14c944fa4dedee378dd4
7
- data.tar.gz: 4ca74695ff40b2660da12eb9f2498c46f301752ebe83429d1b5180499848ea40388f3fc03d8af7c1071df103ac1814df8ebe4cf0eb4ab76af69f522253968228
6
+ metadata.gz: e25e698e0b00458d0f9ed77f9f862ac0a26348e06cde2ebddc7ea3f9208cd4df5edf32bcf6c4832248f12dc1d93274b2126f6e790cc87b7ed8d87be7a15b52d0
7
+ data.tar.gz: 786a7afe69526f66db9e3b5129d362fe8a2ff755cce777d6e85e79adfeee88d63d29c2f394fdce22ea78883d20ebd7641bdd8d9946d8097b02c3aec173b8dd6d
@@ -0,0 +1,54 @@
1
+ (function (Aviate, d3) {
2
+ Aviate.Axes = function () {
3
+ var self = this;
4
+
5
+ var makeXAxis = function () {
6
+ return d3.axisBottom(self.x).ticks(6);
7
+ };
8
+
9
+ var makeYAxis = function () {
10
+ return d3
11
+ .axisLeft(self.y)
12
+ .tickFormat(d3.format(",d"));
13
+ };
14
+
15
+ var xAxis = makeXAxis();
16
+ var yAxis = makeYAxis();
17
+
18
+ return {
19
+ x: xAxis,
20
+ y: yAxis,
21
+ render: function (svg) {
22
+ svg
23
+ .append("g")
24
+ .attr("class", "grid")
25
+ .attr(
26
+ "transform",
27
+ "translate(" + self.margin_left + "," + self.height + ")"
28
+ )
29
+ .call(makeXAxis().tickSize(-self.height).tickFormat(""));
30
+
31
+ svg
32
+ .append("g")
33
+ .attr("class", "grid")
34
+ .attr("transform", "translate(" + self.margin_left + ",0)")
35
+ .call(makeYAxis().tickSize(-self.width).tickFormat(""));
36
+
37
+ svg
38
+ .append("g")
39
+ .attr("class", "x axis")
40
+ .attr(
41
+ "transform",
42
+ "translate(" + self.margin_left + "," + self.height + ")"
43
+ )
44
+ .call(xAxis);
45
+
46
+ svg
47
+ .append("g")
48
+ .attr("class", "y axis")
49
+ .attr("transform", "translate(" + self.margin_left + ",0)")
50
+ .call(yAxis);
51
+ },
52
+ };
53
+ };
54
+ })((window.Aviate = window.Aviate || {}), d3);
@@ -0,0 +1,138 @@
1
+ (function (Aviate, d3) {
2
+ Aviate.LineChart = function () {
3
+ var self = this;
4
+
5
+ this.x = d3.scaleTime().range([0, this.width]);
6
+ this.y = d3.scaleLinear().range([this.height, 0]);
7
+
8
+ var valueline = d3
9
+ .line()
10
+ .x(function (d) {
11
+ return self.x(d.x);
12
+ })
13
+ .y(function (d) {
14
+ return self.y(d.y);
15
+ })
16
+ .curve(d3.curveStepAfter);
17
+
18
+ var axes = Aviate.Axes.apply(this);
19
+ var helper = new Aviate.Helper();
20
+
21
+ var colors = [
22
+ "#2196F3", // blue
23
+ "#E53935", // red
24
+ "#43A047", // green
25
+ "#FB8C00", // orange
26
+ "#8E24AA", // purple
27
+ "#00ACC1", // cyan
28
+ "#F4511E", // deep orange
29
+ "#6D4C41", // brown
30
+ ];
31
+ self.color = d3.scaleOrdinal().range(colors);
32
+
33
+ return {
34
+ render: function (svg, json) {
35
+ var datasets = json.data;
36
+
37
+ datasets.forEach(function (dataset) {
38
+ dataset.values.forEach(function (d) {
39
+ d.date = typeof d.x === "string" ? d.x.split("T")[0] : d.x;
40
+ d.x = helper.parseDate(d.date);
41
+ d.y = +d.y;
42
+ });
43
+ });
44
+
45
+ // Scale the range of the data before rendering axes
46
+ var allValues = datasets.reduce(function (result, element) {
47
+ return result.concat(element.values);
48
+ }, []);
49
+
50
+ var x_domain = d3.extent(allValues, function (d) {
51
+ return d.x;
52
+ });
53
+ self.x.domain(x_domain);
54
+
55
+ var y_domain = d3.extent(allValues, function (d) {
56
+ return d.y;
57
+ });
58
+ self.y.domain(y_domain);
59
+
60
+ // Render axes
61
+ axes.render(svg);
62
+
63
+ self.color.domain(datasets.map(function (dataset) {
64
+ return dataset.name;
65
+ }));
66
+
67
+ // Create legend container to the right of the chart area
68
+ var legendContainer = svg
69
+ .append("g")
70
+ .attr("class", "chart-legend")
71
+ .attr("transform", "translate(" + (self.width + self.margin_left + 15) + ", 10)");
72
+
73
+ // Calculate latest values for legend
74
+ var legendData = datasets.map(function (dataset) {
75
+ var latestValue = dataset.values[dataset.values.length - 1];
76
+ return {
77
+ name: dataset.name,
78
+ value: latestValue ? latestValue.y : 0,
79
+ color: self.color(dataset.name),
80
+ };
81
+ });
82
+
83
+ var legendItems = legendContainer
84
+ .selectAll(".legend-item")
85
+ .data(legendData)
86
+ .enter()
87
+ .append("g")
88
+ .attr("class", "legend-item");
89
+
90
+ var yOffset = 0;
91
+ legendItems.each(function (d) {
92
+ var legendItem = d3.select(this);
93
+
94
+ legendItem
95
+ .append("circle")
96
+ .attr("cx", 6)
97
+ .attr("cy", yOffset)
98
+ .attr("r", 6)
99
+ .style("fill", d.color);
100
+
101
+ var labelText = d.name + ": " + helper.formatValue(d.value);
102
+ legendItem
103
+ .append("text")
104
+ .attr("x", 18)
105
+ .attr("y", yOffset)
106
+ .attr("dy", "0.35em")
107
+ .style("font-size", "0.875rem")
108
+ .style("font-weight", "500")
109
+ .style("fill", "#6B7280")
110
+ .text(labelText);
111
+
112
+ yOffset += 22;
113
+ });
114
+
115
+ // Render data lines (stepped)
116
+ datasets.forEach(function (dataset) {
117
+ var data = dataset.values,
118
+ name = dataset.name;
119
+
120
+ svg
121
+ .append("path")
122
+ .attr("class", "line")
123
+ .attr("d", valueline(data))
124
+ .attr("transform", "translate(" + self.margin_left + ",0)")
125
+ .style("stroke", function () {
126
+ return self.color(name);
127
+ })
128
+ .style("stroke-width", "0.125rem")
129
+ .style("fill", "none")
130
+ .style("opacity", 0.9);
131
+ });
132
+
133
+ self.datasets = datasets;
134
+ Aviate.Utils.MouseOver.apply(self).render(svg, self.x, self.y);
135
+ },
136
+ };
137
+ };
138
+ })((window.Aviate = window.Aviate || {}), d3);
@@ -0,0 +1,149 @@
1
+ (function (Aviate, d3) {
2
+ Aviate.Utils = Aviate.Utils || {};
3
+
4
+ Aviate.Utils.MouseOver = function () {
5
+ var self = this;
6
+ var helper = new Aviate.Helper();
7
+
8
+ return {
9
+ render: function (svg) {
10
+ var focus = svg
11
+ .append("g")
12
+ .attr("class", "focus")
13
+ .style("display", "none");
14
+
15
+ // Vertical line
16
+ focus
17
+ .append("line")
18
+ .attr("class", "mouse-line")
19
+ .attr("y1", 0)
20
+ .attr("y2", self.height)
21
+ .style("stroke", "#999")
22
+ .style("stroke-dasharray", "3,3")
23
+ .style("opacity", 0.5);
24
+
25
+ // Circles for each series
26
+ self.datasets.forEach(function () {
27
+ focus
28
+ .append("circle")
29
+ .attr("r", 4)
30
+ .style("fill", "none")
31
+ .style("stroke-width", 2);
32
+ });
33
+
34
+ // Tooltip
35
+ var tooltip = d3
36
+ .select("body")
37
+ .append("div")
38
+ .attr("class", "chart-tooltip")
39
+ .style("position", "absolute")
40
+ .style("background", "white")
41
+ .style("border", "1px solid #ddd")
42
+ .style("border-radius", "4px")
43
+ .style("padding", "8px 12px")
44
+ .style("font-size", "0.8125rem")
45
+ .style("pointer-events", "none")
46
+ .style("opacity", 0)
47
+ .style("z-index", 1000)
48
+ .style("box-shadow", "0 2px 8px rgba(0,0,0,0.1)");
49
+
50
+ // Overlay rect to capture mouse
51
+ svg
52
+ .append("rect")
53
+ .attr("class", "overlay")
54
+ .attr("width", self.width)
55
+ .attr("height", self.height)
56
+ .attr("transform", "translate(" + self.margin_left + ",0)")
57
+ .style("fill", "none")
58
+ .style("pointer-events", "all")
59
+ .on("mouseover", function () {
60
+ focus.style("display", null);
61
+ tooltip.style("opacity", 1);
62
+ })
63
+ .on("mouseout", function () {
64
+ focus.style("display", "none");
65
+ tooltip.style("opacity", 0);
66
+ })
67
+ .on("mousemove", function () {
68
+ var mouseX = d3.pointer(event, this)[0];
69
+ var x0 = self.x.invert(mouseX);
70
+
71
+ // Find closest data point for each dataset
72
+ var tooltipRows = [];
73
+ var closestDate = null;
74
+ self.datasets.forEach(function (dataset, i) {
75
+ var data = dataset.values;
76
+ var idx = helper.bisectDate(data, x0, 1);
77
+ var d0 = data[idx - 1];
78
+ var d1 = data[idx];
79
+ if (!d0 && !d1) return;
80
+ var d = d0 && d1
81
+ ? x0 - d0.x > d1.x - x0 ? d1 : d0
82
+ : d0 || d1;
83
+
84
+ tooltipRows.push({
85
+ name: dataset.name,
86
+ value: d.y,
87
+ color: self.color(dataset.name),
88
+ });
89
+
90
+ // Use the matched data point's own date (a "%Y-%m-%d" string) for the
91
+ // tooltip header, not the raw mouse position - self.x/self.y are pixel
92
+ // scales, not formatters, and re-scaling x0 through them produced a
93
+ // meaningless pixel offset instead of a date.
94
+ if (closestDate === null) {
95
+ closestDate = d.date;
96
+ }
97
+
98
+ focus
99
+ .selectAll("circle")
100
+ .filter(function (_, j) { return j === i; })
101
+ .attr("cx", self.x(d.x) + self.margin_left)
102
+ .attr("cy", self.y(d.y));
103
+ });
104
+
105
+ // Update vertical line
106
+ focus
107
+ .select(".mouse-line")
108
+ .attr("x1", self.x(x0) + self.margin_left)
109
+ .attr("x2", self.x(x0) + self.margin_left);
110
+
111
+ // Sort by value descending for tooltip
112
+ tooltipRows.sort(function (a, b) { return b.value - a.value; });
113
+
114
+ var dateStr = helper.formatDate(closestDate);
115
+ var rowsHtml = tooltipRows.map(function (row) {
116
+ return (
117
+ '<div style="display:flex;align-items:center;gap:6px;margin:2px 0;">' +
118
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + row.color + ';flex-shrink:0;"></span>' +
119
+ '<span>' + row.name + ":</span>" +
120
+ '<span style="font-weight:600;">' + helper.formatValue(row.value) + "</span>" +
121
+ "</div>"
122
+ );
123
+ });
124
+
125
+ tooltip
126
+ .html(
127
+ '<div style="font-weight:600;margin-bottom:4px;">' + dateStr + "</div>" +
128
+ rowsHtml.join("")
129
+ );
130
+
131
+ // Position tooltip, flip to left when near right edge
132
+ var tooltipNode = tooltip.node();
133
+ var tooltipWidth = tooltipNode ? tooltipNode.getBoundingClientRect().width : 200;
134
+ var chartRect = svg.node().getBoundingClientRect();
135
+ var mousePageX = d3.pointer(event, document.body)[0];
136
+ var leftPos = mousePageX + 15;
137
+
138
+ if (leftPos + tooltipWidth > window.innerWidth - 20) {
139
+ leftPos = mousePageX - tooltipWidth - 15;
140
+ }
141
+
142
+ tooltip
143
+ .style("left", leftPos + "px")
144
+ .style("top", chartRect.top + "px");
145
+ });
146
+ },
147
+ };
148
+ };
149
+ })((window.Aviate = window.Aviate || {}), d3);
@@ -0,0 +1,52 @@
1
+ (function (Aviate, d3) {
2
+ Aviate.Helper = function () {
3
+ var formatValue = function (d) {
4
+ return d % 1 === 0 ? d3.format(",d")(d) : d3.format(",.2f")(d);
5
+ };
6
+ var formatCurrency = function (d) {
7
+ return "$" + formatValue(d);
8
+ };
9
+
10
+ var humanizeSegment = function (segment) {
11
+ segment = String(segment || "")
12
+ .replace(/[_-]+/g, " ")
13
+ .replace(/\s+/g, " ")
14
+ .trim();
15
+
16
+ if (!segment) {
17
+ return "";
18
+ }
19
+
20
+ return segment
21
+ .split(" ")
22
+ .map(function (word) {
23
+ if (/^\d+(\.\d+)?$/.test(word)) {
24
+ return word;
25
+ }
26
+ if (/^[A-Z]{2,3}$/.test(word)) {
27
+ return word;
28
+ }
29
+
30
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
31
+ })
32
+ .join(" ");
33
+ };
34
+
35
+ var parseDateFn = d3.timeParse("%Y-%m-%d");
36
+ var formatDateFn = d3.timeFormat("%b %d, %Y");
37
+ var formatDate = function (dateStr) {
38
+ var parsed = parseDateFn(dateStr);
39
+ return parsed ? formatDateFn(parsed) : dateStr;
40
+ };
41
+
42
+ return {
43
+ parseDate: parseDateFn,
44
+ bisectDate: d3.bisector(function (d) {
45
+ return d.x;
46
+ }).left,
47
+ formatCurrency: formatCurrency,
48
+ formatValue: formatValue,
49
+ formatDate: formatDate,
50
+ };
51
+ };
52
+ })((window.Aviate = window.Aviate || {}), d3);
@@ -0,0 +1,36 @@
1
+ (function (Aviate, d3) {
2
+ Aviate.Renderer = function (selector) {
3
+ this.element = d3.select(selector);
4
+
5
+ var settings = Aviate.Settings.apply(this);
6
+
7
+ var svg = this.element
8
+ .append("svg")
9
+ .attr("width", settings.raw_width)
10
+ .attr("height", settings.raw_height)
11
+ .attr("style", "overflow: visible")
12
+ .append("g")
13
+ .attr(
14
+ "transform",
15
+ "translate(" + settings.margin_left + "," + settings.margin_top + ")"
16
+ );
17
+
18
+ return {
19
+ lineChart: function (data) {
20
+ var chart = Aviate.LineChart.apply(settings);
21
+ chart.render(svg, data);
22
+ },
23
+
24
+ noData: function () {
25
+ svg
26
+ .append("text")
27
+ .attr("class", "chart-info")
28
+ .attr(
29
+ "transform",
30
+ "translate(" + settings.width / 2 + "," + settings.height / 2 + ")"
31
+ )
32
+ .text("No data to display.");
33
+ },
34
+ };
35
+ };
36
+ })((window.Aviate = window.Aviate || {}), d3);
@@ -0,0 +1,24 @@
1
+ (function (Aviate, d3) {
2
+ Aviate.Settings = function () {
3
+ var margin_top = 30,
4
+ margin_bottom = 40,
5
+ margin_left = 50,
6
+ margin_right = 150,
7
+ raw_width = parseInt(
8
+ this.element.node().getBoundingClientRect().width,
9
+ 10
10
+ ),
11
+ raw_height = 500;
12
+
13
+ return {
14
+ margin_top: margin_top,
15
+ margin_bottom: margin_bottom,
16
+ margin_left: margin_left,
17
+ margin_right: margin_right,
18
+ raw_width: raw_width,
19
+ raw_height: raw_height,
20
+ width: raw_width - margin_left - margin_right,
21
+ height: raw_height - margin_top - margin_bottom,
22
+ };
23
+ };
24
+ })((window.Aviate = window.Aviate || {}), d3);
@@ -8,5 +8,6 @@
8
8
  // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
9
9
  // about supported directives.
10
10
  //
11
+ //= require d3
11
12
  //= require aviate/aviate
12
13
  //= require assets/common
@@ -0,0 +1,12 @@
1
+ .usage-index .usage-header {
2
+ padding: 0 0 1rem;
3
+ border-bottom: 0.0625rem solid #e9eaeb;
4
+ }
5
+
6
+ .usage-index .usage-header h2 {
7
+ font-weight: 600;
8
+ font-size: 1.125rem;
9
+ line-height: 1.75rem;
10
+ color: #414651;
11
+ margin-bottom: 0.5rem;
12
+ }
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Aviate
6
+ class UsageController < Aviate::EngineController
7
+ before_action :aviate_authentication
8
+
9
+ def index
10
+ cached_options = options_for_klient
11
+ @account_id = params.require(:account_id)
12
+ @account = Kaui::Account.find_by_id(@account_id, false, false, cached_options)
13
+
14
+ bundles = @account.bundles(cached_options)
15
+ @subscriptions = []
16
+ bundles.each do |bundle|
17
+ (bundle.subscriptions || []).each do |sub|
18
+ @subscriptions << sub if sub.billing_end_date.blank?
19
+ end
20
+ end
21
+ end
22
+
23
+ def show
24
+ cached_options = options_for_klient
25
+ subscription_id = params.require(:id)
26
+
27
+ @subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', cached_options)
28
+ @account_id = @subscription.account_id
29
+ @account = Kaui::Account.find_by_id(@account_id, false, false, cached_options)
30
+
31
+ start_date, end_date = billing_period(@subscription)
32
+ @period_start = format_date(start_date)
33
+ @period_end = format_date(end_date)
34
+ @period_closed = end_date < Time.zone.today
35
+
36
+ # Step 3: Retrieve unit types from catalog
37
+ unit_types = fetch_unit_types(@subscription, start_date, cached_options)
38
+
39
+ # Step 4: Fetch daily usage time-series in parallel
40
+ samples_by_unit = fetch_all_samples(subscription_id, unit_types, start_date, end_date, cached_options)
41
+
42
+ # Step 5: Build cumulative series per unit type
43
+ @chart_data = build_chart_data(samples_by_unit, unit_types)
44
+
45
+ # Step 6: Fetch invoice dry run for accrued cost
46
+ @accrued_cost = fetch_accrued_cost(@account_id, cached_options)
47
+
48
+ # Step 7: Fetch wallet balance
49
+ @wallet_balance = fetch_wallet_balance(@account_id, cached_options)
50
+
51
+ # Build chart JSON for the view
52
+ @chart_json = build_chart_json(@chart_data)
53
+ end
54
+
55
+ private
56
+
57
+ def billing_period(subscription)
58
+ start_date = parse_kb_date(subscription.charged_through_date)
59
+ end_date = compute_next_bcd(start_date, subscription.bill_cycle_day_local)
60
+ [start_date, end_date]
61
+ end
62
+
63
+ def parse_kb_date(date_str)
64
+ return Time.zone.today if date_str.blank?
65
+
66
+ Date.parse(date_str.to_s)
67
+ rescue ArgumentError
68
+ Time.zone.today
69
+ end
70
+
71
+ def compute_next_bcd(start_date, bill_cycle_day_local)
72
+ return start_date + 30 if bill_cycle_day_local.blank?
73
+
74
+ bcd = bill_cycle_day_local.to_i
75
+ bcd = 28 if bcd > 28
76
+ bcd = 1 if bcd < 1
77
+
78
+ if start_date.day < bcd
79
+ next_date = Date.new(start_date.year, start_date.month, bcd)
80
+ else
81
+ next_month = start_date.next_month
82
+ last_day = [bcd, Date.new(next_month.year, next_month.month, -1).day].min
83
+ next_date = Date.new(next_month.year, next_month.month, last_day)
84
+ end
85
+
86
+ next_date
87
+ rescue ArgumentError
88
+ start_date + 30
89
+ end
90
+
91
+ def fetch_unit_types(subscription, start_date, cached_options)
92
+ # NOTE: KillBillClient::Model::Catalog.get_catalog_phase parses the response as a
93
+ # Catalog object by default, but the API actually returns a Phase-shaped payload
94
+ # (it has no `usages` accessor otherwise) - request it as a Phase explicitly.
95
+ phase = KillBillClient::Model::Phase.get(
96
+ "#{KillBillClient::Model::Catalog::KILLBILL_API_CATALOG_PREFIX}/phase",
97
+ {
98
+ subscriptionId: subscription.subscription_id,
99
+ requestedDate: start_date.to_s
100
+ },
101
+ cached_options
102
+ )
103
+
104
+ return [] if phase.nil? || phase.usages.blank?
105
+
106
+ unit_types = []
107
+ Array(phase.usages).each do |usage|
108
+ usage_hash = usage.respond_to?(:to_hash) ? usage.to_hash : usage
109
+ tiers = usage_hash['tiers'] || usage_hash[:tiers] || []
110
+ Array(tiers).each do |tier|
111
+ tier_hash = tier.respond_to?(:to_hash) ? tier.to_hash : tier
112
+ blocks = tier_hash['blocks'] || tier_hash[:blocks] || []
113
+ Array(blocks).each do |block|
114
+ block_hash = block.respond_to?(:to_hash) ? block.to_hash : block
115
+ unit = block_hash['unit'] || block_hash[:unit]
116
+ unit_types << unit if unit.present?
117
+ end
118
+ end
119
+ end
120
+
121
+ unit_types.uniq.compact
122
+ rescue StandardError => e
123
+ Rails.logger.warn("Failed to fetch catalog phase: #{e.message}")
124
+ []
125
+ end
126
+
127
+ def fetch_all_samples(subscription_id, unit_types, start_date, end_date, cached_options)
128
+ samples_by_unit = {}
129
+ from = start_date.to_s
130
+ to = end_date.to_s
131
+
132
+ return samples_by_unit if unit_types.empty?
133
+
134
+ # rubocop:disable ThreadSafety/NewThread -- fetching samples for each unit type in parallel is intentional
135
+ threads = unit_types.map do |unit_type|
136
+ Thread.new do
137
+ samples = Killbill::Aviate::AviateClient.host_samples(
138
+ subscription_id, unit_type, from, to, cached_options
139
+ )
140
+ [unit_type, samples]
141
+ rescue StandardError => e
142
+ Rails.logger.warn("host_samples failed for #{unit_type}: #{e.message}")
143
+ [unit_type, []]
144
+ end
145
+ end
146
+ # rubocop:enable ThreadSafety/NewThread
147
+
148
+ threads.each do |t|
149
+ unit_type, samples = t.value
150
+ samples_by_unit[unit_type] = samples
151
+ end
152
+
153
+ samples_by_unit
154
+ end
155
+
156
+ def build_chart_data(samples_by_unit, unit_types)
157
+ return {} if samples_by_unit.empty?
158
+
159
+ chart_data = {}
160
+ unit_types.each do |unit_type|
161
+ samples = samples_by_unit[unit_type] || []
162
+ next if samples.empty?
163
+
164
+ cumulative = 0
165
+ values = samples.map do |s|
166
+ cumulative += s[:value]
167
+ { 'x' => s[:date]&.split('T')&.first || s[:date], 'y' => cumulative }
168
+ end
169
+
170
+ chart_data[unit_type] = values
171
+ end
172
+
173
+ chart_data
174
+ end
175
+
176
+ def build_chart_json(chart_data)
177
+ return nil if chart_data.empty?
178
+
179
+ series = chart_data.map do |unit_type, values|
180
+ { 'name' => unit_type, 'values' => values }
181
+ end
182
+
183
+ { 'type' => 'TIMELINE', 'data' => series }.to_json
184
+ end
185
+
186
+ def fetch_accrued_cost(account_id, cached_options)
187
+ # Use the UPCOMING_INVOICE dry-run type (matches Kaui's own next_invoice_date action) so
188
+ # that in-arrear usage recorded so far in the still-open period is reflected. A plain
189
+ # TARGET_DATE dry-run with no date returns 204/nil here, since there's no invoicing
190
+ # event actually scheduled for "now" mid-period.
191
+ invoice = KillBillClient::Model::Invoice.trigger_invoice_dry_run(
192
+ account_id,
193
+ nil,
194
+ true,
195
+ [],
196
+ current_user&.kb_username,
197
+ nil,
198
+ nil,
199
+ cached_options
200
+ )
201
+
202
+ return nil if invoice.nil? || invoice.amount.nil?
203
+
204
+ invoice.amount
205
+ rescue StandardError => e
206
+ Rails.logger.warn("Invoice dry run failed: #{e.message}")
207
+ nil
208
+ end
209
+
210
+ def fetch_wallet_balance(account_id, cached_options)
211
+ wallets = Killbill::Aviate::AviateClient.retrieve_wallets(account_id, cached_options)
212
+ return nil if wallets.blank?
213
+
214
+ wallets.sum { |w| (w['balance'] || 0).to_f }
215
+ rescue StandardError => e
216
+ Rails.logger.warn("Wallet balance fetch failed: #{e.message}")
217
+ nil
218
+ end
219
+
220
+ def format_date(date)
221
+ date.strftime('%b %d, %Y')
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,70 @@
1
+ <div class="kaui-container usage-index">
2
+ <%= render "kaui/components/breadcrumb/breadcrumb" %>
3
+ <div class="d-flex" style="gap: 4rem;">
4
+ <%= render :template => 'kaui/layouts/kaui_account_sidebar' %>
5
+ <div class="usage-content" style="max-width: 67.5rem; width: 100%;">
6
+ <div class="usage-header mb-4">
7
+ <h2>Usage</h2>
8
+ <p class="text-muted">Select a subscription to view its metered usage.</p>
9
+ </div>
10
+
11
+ <% if @subscriptions.empty? %>
12
+ <div class="text-center py-5 text-muted">
13
+ <p>No active subscriptions found for this account.</p>
14
+ </div>
15
+ <% else %>
16
+ <table id="usage-table" class="usage-table">
17
+ <thead>
18
+ <tr>
19
+ <th>Subscription</th>
20
+ <th>Plan</th>
21
+ <th>Product</th>
22
+ <th>Start Date</th>
23
+ <th></th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <% @subscriptions.each do |sub| %>
28
+ <tr>
29
+ <td>
30
+ <span class="object-id-popover" data-id="<%= sub.subscription_id %>">
31
+ <%= truncate(sub.subscription_id, length: 12) %>
32
+ </span>
33
+ </td>
34
+ <td><%= sub.plan_name || '—' %></td>
35
+ <td><%= sub.product_name || '—' %></td>
36
+ <td><%= sub.start_date.present? ? Date.parse(sub.start_date.to_s).strftime('%b %d, %Y') : '—' %></td>
37
+ <td class="text-end">
38
+ <%= render "kaui/components/button/button", {
39
+ label: "View Usage",
40
+ variant: "outline-secondary d-inline-flex align-items-center gap-1",
41
+ type: "button",
42
+ html_class: "kaui-button custom-hover",
43
+ html_options: {
44
+ onclick: "window.location.href='#{aviate_engine.usage_subscription_path(id: sub.subscription_id)}'"
45
+ }
46
+ } %>
47
+ </td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+ </table>
52
+ <% end %>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <% unless @subscriptions.empty? %>
58
+ <%= javascript_tag do %>
59
+
60
+ $(document).ready(function() {
61
+ var table = $('#usage-table').DataTable({
62
+ "processing": false,
63
+ "info": false,
64
+ "paging": false,
65
+ "searching": false,
66
+ "ordering": false
67
+ });
68
+ });
69
+ <% end %>
70
+ <% end %>
@@ -0,0 +1,92 @@
1
+ <div class="kaui-container usage-show">
2
+ <%= render "kaui/components/breadcrumb/breadcrumb" %>
3
+ <div class="d-flex" style="gap: 4rem;">
4
+ <%= render :template => 'kaui/layouts/kaui_account_sidebar' %>
5
+ <div class="usage-content" style="max-width: 67.5rem; width: 100%;">
6
+ <div class="usage-header mb-4">
7
+ <h2>Usage</h2>
8
+ <p class="text-muted">
9
+ Subscription: <strong><%= @subscription.plan_name || @subscription.product_name %></strong>
10
+ &mdash; <%= @period_start %> to <%= @period_end %>
11
+ </p>
12
+ </div>
13
+
14
+ <% if @chart_json.present? || @accrued_cost.present? || @wallet_balance.present? %>
15
+ <div class="usage-stats d-flex gap-4 mb-4">
16
+ <% if @chart_data.any? %>
17
+ <% @chart_data.each do |unit_type, values| %>
18
+ <div class="stat-card card p-3">
19
+ <div class="stat-label text-muted small">Units Consumed (<%= unit_type %>)</div>
20
+ <div class="stat-value h4"><%= values.last&.dig('y') || 0 %></div>
21
+ </div>
22
+ <% end %>
23
+ <% end %>
24
+
25
+ <% if @accrued_cost.present? %>
26
+ <div class="stat-card card p-3">
27
+ <div class="stat-label text-muted small"><%= @period_closed ? 'Invoice Amount' : 'Accrued Cost (today)' %></div>
28
+ <div class="stat-value h4"><%= number_to_currency(@accrued_cost) %></div>
29
+ </div>
30
+ <% end %>
31
+
32
+ <% if @wallet_balance.present? %>
33
+ <div class="stat-card card p-3">
34
+ <div class="stat-label text-muted small">Wallet Balance</div>
35
+ <div class="stat-value h4"><%= number_to_currency(@wallet_balance) %></div>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+ <% end %>
40
+
41
+ <div class="chart-container card p-3">
42
+ <% if @chart_json.present? %>
43
+ <div id="loading-spinner" class="text-center py-5">
44
+ <div class="spinner-border" role="status">
45
+ <span class="visually-hidden">Loading...</span>
46
+ </div>
47
+ </div>
48
+ <div id="aviateUsageChartAnchor"
49
+ data-chart-json='<%= raw @chart_json %>'
50
+ style="width: 100%;">
51
+ </div>
52
+ <% else %>
53
+ <div class="text-center py-5 text-muted">
54
+ <% if @chart_data.empty? %>
55
+ <p>No usage data available for this period.</p>
56
+ <% else %>
57
+ <p>Unable to render chart.</p>
58
+ <% end %>
59
+ <% if @accrued_cost.nil? && @wallet_balance.nil? %>
60
+ <p>No usage, cost, or wallet data available.</p>
61
+ <% end %>
62
+ </div>
63
+ <% end %>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+
69
+ <% if @chart_json.present? %>
70
+ <%= javascript_include_tag 'aviate_application' %>
71
+
72
+ <%= javascript_tag do %>
73
+ $(document).ready(function() {
74
+ if ($("#aviateUsageChartAnchor").length === 0) return;
75
+
76
+ var chartJson = $("#aviateUsageChartAnchor").data("chart-json");
77
+ if (!chartJson || !chartJson.data || chartJson.data.length === 0) {
78
+ $("#loading-spinner").remove();
79
+ return;
80
+ }
81
+
82
+ try {
83
+ var renderer = new Aviate.Renderer("#aviateUsageChartAnchor");
84
+ renderer.lineChart(chartJson);
85
+ } catch (ex) {
86
+ console.error("Chart render error:", ex);
87
+ } finally {
88
+ $("#loading-spinner").remove();
89
+ }
90
+ });
91
+ <% end %>
92
+ <% end %>
data/config/routes.rb CHANGED
@@ -17,5 +17,8 @@ Aviate::Engine.routes.draw do
17
17
  post :top_off_config
18
18
  end
19
19
  end
20
+ resources :usage, only: [:index]
20
21
  end
22
+
23
+ get 'subscriptions/:id/usage', to: 'usage#show', as: :usage_subscription
21
24
  end
data/lib/aviate/client.rb CHANGED
@@ -79,6 +79,21 @@ module Killbill
79
79
  JSON.parse(e.message)
80
80
  end
81
81
 
82
+ def host_samples(subscription_id, sample_kind, from_date, to_date, options = nil)
83
+ path = "#{KILLBILL_AVIATE_PREFIX}/health/host_samples"
84
+ query_params = {
85
+ from: from_date,
86
+ to: to_date,
87
+ category_and_sample_kind: "#{subscription_id},#{sample_kind}"
88
+ }
89
+
90
+ request_options = build_request_options(options)
91
+ response = KillBillClient::API.get path, query_params, request_options
92
+ parse_csv_response(response.body)
93
+ rescue KillBillClient::API::ResponseError
94
+ []
95
+ end
96
+
82
97
  def authenticate(email, password, options = nil)
83
98
  path = "#{KILLBILL_AVIATE_PREFIX}/auth"
84
99
  auth = Base64.strict_encode64("#{email}:#{password}")
@@ -102,6 +117,21 @@ module Killbill
102
117
 
103
118
  private
104
119
 
120
+ def parse_csv_response(body)
121
+ return [] if body.nil? || body.strip.empty?
122
+
123
+ require 'csv'
124
+ rows = CSV.parse(body, headers: true)
125
+ rows.map do |row|
126
+ {
127
+ date: row['timestamp']&.strip,
128
+ value: row['value']&.strip&.to_f
129
+ }
130
+ end
131
+ rescue CSV::MalformedCSVError
132
+ []
133
+ end
134
+
105
135
  def build_request_options(options)
106
136
  return {} if options.nil?
107
137
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aviate
4
- VERSION = '2.3.0'
4
+ VERSION = '2.4.0.pre.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: killbill-aviate
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.4.0.pre.1
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-15 00:00:00.000000000 Z
11
+ date: 2026-07-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: killbill-assets-ui
@@ -77,17 +77,27 @@ files:
77
77
  - Rakefile
78
78
  - app/assets/config/aviate_manifest.js
79
79
  - app/assets/javascripts/aviate/aviate.js
80
+ - app/assets/javascripts/aviate/kiddo/axes.js
81
+ - app/assets/javascripts/aviate/kiddo/charts/line_chart.js
82
+ - app/assets/javascripts/aviate/kiddo/charts/utils/mouse_over.js
83
+ - app/assets/javascripts/aviate/kiddo/helper.js
84
+ - app/assets/javascripts/aviate/kiddo/renderer.js
85
+ - app/assets/javascripts/aviate/kiddo/settings.js
80
86
  - app/assets/javascripts/aviate_application.js
81
87
  - app/assets/stylesheets/aviate/aviate.css
88
+ - app/assets/stylesheets/aviate/usage.css
82
89
  - app/assets/stylesheets/aviate_application.css
83
90
  - app/controllers/aviate/configuration_controller.rb
84
91
  - app/controllers/aviate/engine_controller.rb
92
+ - app/controllers/aviate/usage_controller.rb
85
93
  - app/controllers/aviate/wallets_controller.rb
86
94
  - app/helpers/aviate/application_helper.rb
87
95
  - app/services/aviate/wallet.rb
88
96
  - app/services/aviate/wallet_api.rb
89
97
  - app/views/aviate/configuration/index.html.erb
90
98
  - app/views/aviate/layouts/aviate_application.html.erb
99
+ - app/views/aviate/usage/index.html.erb
100
+ - app/views/aviate/usage/show.html.erb
91
101
  - app/views/aviate/wallets/_form.html.erb
92
102
  - app/views/aviate/wallets/index.html.erb
93
103
  - app/views/aviate/wallets/new.html.erb
@@ -114,9 +124,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
114
124
  version: '0'
115
125
  required_rubygems_version: !ruby/object:Gem::Requirement
116
126
  requirements:
117
- - - ">="
127
+ - - ">"
118
128
  - !ruby/object:Gem::Version
119
- version: '0'
129
+ version: 1.3.1
120
130
  requirements: []
121
131
  rubygems_version: 3.4.10
122
132
  signing_key: