redis_cloud/cost_report.rs
1//! Cost Report Generation and Retrieval
2//!
3//! This module provides functionality for generating and downloading cost reports
4//! in FOCUS format from Redis Cloud. FOCUS (FinOps Cost and Usage Specification)
5//! is an open standard for cloud cost data.
6//!
7//! # Overview
8//!
9//! Cost reports provide detailed billing information for your Redis Cloud resources,
10//! allowing you to analyze costs by subscription, database, region, and custom tags.
11//!
12//! # Report Generation Flow
13//!
14//! 1. **Generate Request**: Submit a cost report request with date range and filters
15//! 2. **Track Task**: Monitor the async task until completion
16//! 3. **Download Report**: Retrieve the generated report using the costReportId
17//!
18//! # Key Features
19//!
20//! - **Date Range Filtering**: Specify start and end dates (max 40 days)
21//! - **Output Formats**: CSV or JSON
22//! - **Subscription Filtering**: Filter by specific subscription IDs
23//! - **Database Filtering**: Filter by specific database IDs
24//! - **Plan Type Filtering**: Filter by "pro" or "essentials"
25//! - **Region Filtering**: Filter by cloud regions
26//! - **Tag Filtering**: Filter by custom key-value tags
27//!
28//! # Example Usage
29//!
30//! ```no_run
31//! use redis_cloud::{CloudClient, CostReportHandler, CostReportCreateRequest, CostReportFormat};
32//!
33//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
34//! let client = CloudClient::builder()
35//! .api_key("your-api-key")
36//! .api_secret("your-api-secret")
37//! .build()?;
38//!
39//! let handler = CostReportHandler::new(client);
40//!
41//! // Generate a cost report for the last month
42//! let request = CostReportCreateRequest::builder()
43//! .start_date("2025-01-01")
44//! .end_date("2025-01-31")
45//! .format(CostReportFormat::Csv)
46//! .build();
47//!
48//! let task = handler.generate_cost_report(request).await?;
49//! println!("Task ID: {:?}", task.task_id);
50//!
51//! // Once the task completes, download the report
52//! // The costReportId is returned in the task response
53//! let report_bytes = handler.download_cost_report("cost-report-12345").await?;
54//! std::fs::write("cost-report.csv", report_bytes)?;
55//! # Ok(())
56//! # }
57//! ```
58//!
59//! # FOCUS Format
60//!
61//! The cost report follows the [FOCUS specification](https://focus.finops.org/),
62//! providing standardized columns including:
63//! - BilledCost, EffectiveCost, ListCost, ContractedCost
64//! - Resource identifiers (subscription, database)
65//! - Service categories and SKU details
66//! - Billing period and usage information
67
68use crate::{CloudClient, Result, tasks::TaskStateUpdate};
69use serde::{Deserialize, Serialize};
70use serde_json::Value;
71
72// ============================================================================
73// Models
74// ============================================================================
75
76/// Output format for cost reports
77#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
78#[serde(rename_all = "lowercase")]
79pub enum CostReportFormat {
80 /// CSV format (default)
81 #[default]
82 Csv,
83 /// JSON format
84 Json,
85}
86
87impl std::fmt::Display for CostReportFormat {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 CostReportFormat::Csv => write!(f, "csv"),
91 CostReportFormat::Json => write!(f, "json"),
92 }
93 }
94}
95
96/// Subscription type filter for cost reports
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "lowercase")]
99pub enum SubscriptionType {
100 /// Pro subscriptions (pay-as-you-go)
101 Pro,
102 /// Essentials subscriptions (fixed plans)
103 Essentials,
104}
105
106impl std::fmt::Display for SubscriptionType {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 SubscriptionType::Pro => write!(f, "pro"),
110 SubscriptionType::Essentials => write!(f, "essentials"),
111 }
112 }
113}
114
115/// Tag filter for cost reports
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Tag {
118 /// Tag key
119 pub key: String,
120 /// Tag value
121 pub value: String,
122}
123
124impl Tag {
125 /// Create a new tag filter
126 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
127 Self {
128 key: key.into(),
129 value: value.into(),
130 }
131 }
132}
133
134/// Request to generate a cost report
135///
136/// Cost reports are generated asynchronously. After submitting a request,
137/// you'll receive a task ID that can be used to track the generation progress.
138/// Once complete, use the costReportId from the task response to download the report.
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140#[serde(rename_all = "camelCase")]
141pub struct CostReportCreateRequest {
142 /// Start date for the report (YYYY-MM-DD format, required)
143 pub start_date: String,
144
145 /// End date for the report (YYYY-MM-DD format, required)
146 /// Must be after start_date and within 40 days of start_date
147 pub end_date: String,
148
149 /// Output format (csv or json, defaults to csv)
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub format: Option<CostReportFormat>,
152
153 /// Filter by subscription IDs
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub subscription_ids: Option<Vec<i32>>,
156
157 /// Filter by database IDs
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub database_ids: Option<Vec<i32>>,
160
161 /// Filter by subscription type (pro or essentials)
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub subscription_type: Option<SubscriptionType>,
164
165 /// Filter by regions
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub regions: Option<Vec<String>>,
168
169 /// Filter by tags (key-value pairs)
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub tags: Option<Vec<Tag>>,
172
173 /// Additional fields for forward compatibility
174 #[serde(flatten)]
175 pub extra: Value,
176}
177
178impl CostReportCreateRequest {
179 /// Create a new cost report request builder
180 pub fn builder() -> CostReportCreateRequestBuilder {
181 CostReportCreateRequestBuilder::default()
182 }
183
184 /// Create a simple request with just date range
185 pub fn new(start_date: impl Into<String>, end_date: impl Into<String>) -> Self {
186 Self {
187 start_date: start_date.into(),
188 end_date: end_date.into(),
189 ..Default::default()
190 }
191 }
192}
193
194/// Builder for CostReportCreateRequest
195#[derive(Debug, Clone, Default)]
196pub struct CostReportCreateRequestBuilder {
197 start_date: Option<String>,
198 end_date: Option<String>,
199 format: Option<CostReportFormat>,
200 subscription_ids: Option<Vec<i32>>,
201 database_ids: Option<Vec<i32>>,
202 subscription_type: Option<SubscriptionType>,
203 regions: Option<Vec<String>>,
204 tags: Option<Vec<Tag>>,
205}
206
207impl CostReportCreateRequestBuilder {
208 /// Set the start date (required, YYYY-MM-DD format)
209 pub fn start_date(mut self, date: impl Into<String>) -> Self {
210 self.start_date = Some(date.into());
211 self
212 }
213
214 /// Set the end date (required, YYYY-MM-DD format)
215 pub fn end_date(mut self, date: impl Into<String>) -> Self {
216 self.end_date = Some(date.into());
217 self
218 }
219
220 /// Set the output format
221 pub fn format(mut self, format: CostReportFormat) -> Self {
222 self.format = Some(format);
223 self
224 }
225
226 /// Filter by subscription IDs
227 pub fn subscription_ids(mut self, ids: Vec<i32>) -> Self {
228 self.subscription_ids = Some(ids);
229 self
230 }
231
232 /// Filter by database IDs
233 pub fn database_ids(mut self, ids: Vec<i32>) -> Self {
234 self.database_ids = Some(ids);
235 self
236 }
237
238 /// Filter by subscription type
239 pub fn subscription_type(mut self, sub_type: SubscriptionType) -> Self {
240 self.subscription_type = Some(sub_type);
241 self
242 }
243
244 /// Filter by regions
245 pub fn regions(mut self, regions: Vec<String>) -> Self {
246 self.regions = Some(regions);
247 self
248 }
249
250 /// Filter by tags
251 pub fn tags(mut self, tags: Vec<Tag>) -> Self {
252 self.tags = Some(tags);
253 self
254 }
255
256 /// Add a single tag filter
257 pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
258 let tag = Tag::new(key, value);
259 match &mut self.tags {
260 Some(tags) => tags.push(tag),
261 None => self.tags = Some(vec![tag]),
262 }
263 self
264 }
265
266 /// Build the request
267 ///
268 /// # Panics
269 /// Panics if start_date or end_date is not set
270 pub fn build(self) -> CostReportCreateRequest {
271 CostReportCreateRequest {
272 start_date: self.start_date.expect("start_date is required"),
273 end_date: self.end_date.expect("end_date is required"),
274 format: self.format,
275 subscription_ids: self.subscription_ids,
276 database_ids: self.database_ids,
277 subscription_type: self.subscription_type,
278 regions: self.regions,
279 tags: self.tags,
280 extra: Value::Null,
281 }
282 }
283}
284
285// ============================================================================
286// Handler
287// ============================================================================
288
289/// Handler for cost report operations
290///
291/// Provides methods to generate and download cost reports in FOCUS format.
292pub struct CostReportHandler {
293 client: CloudClient,
294}
295
296impl CostReportHandler {
297 /// Create a new handler
298 pub fn new(client: CloudClient) -> Self {
299 Self { client }
300 }
301
302 /// Generate a cost report (Beta)
303 ///
304 /// Generates a cost report in FOCUS format for the specified time period
305 /// and filters. The maximum date range is 40 days.
306 ///
307 /// This is an asynchronous operation. The returned TaskStateUpdate contains
308 /// a task_id that can be used to track progress. Once complete, the task
309 /// response will contain the costReportId needed to download the report.
310 ///
311 /// POST /cost-report
312 ///
313 /// # Arguments
314 /// * `request` - The cost report generation request with date range and filters
315 ///
316 /// # Returns
317 /// A TaskStateUpdate with the task ID for tracking the generation
318 ///
319 /// # Example
320 /// ```no_run
321 /// # use redis_cloud::{CloudClient, CostReportHandler, CostReportCreateRequest};
322 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
323 /// # let client = CloudClient::builder().api_key("k").api_secret("s").build()?;
324 /// let handler = CostReportHandler::new(client);
325 /// let request = CostReportCreateRequest::new("2025-01-01", "2025-01-31");
326 /// let task = handler.generate_cost_report(request).await?;
327 /// println!("Task ID: {:?}", task.task_id);
328 /// # Ok(())
329 /// # }
330 /// ```
331 pub async fn generate_cost_report(
332 &self,
333 request: CostReportCreateRequest,
334 ) -> Result<TaskStateUpdate> {
335 self.client.post("/cost-report", &request).await
336 }
337
338 /// Generate a cost report and return raw JSON response
339 ///
340 /// POST /cost-report
341 pub async fn generate_cost_report_raw(
342 &self,
343 request: CostReportCreateRequest,
344 ) -> Result<Value> {
345 let body = serde_json::to_value(request).map_err(crate::CloudError::from)?;
346 self.client.post_raw("/cost-report", body).await
347 }
348
349 /// Download a generated cost report (Beta)
350 ///
351 /// Returns the generated cost report file in FOCUS format. The costReportId
352 /// is obtained from the task response after the generation task completes.
353 ///
354 /// GET /cost-report/{costReportId}
355 ///
356 /// # Arguments
357 /// * `cost_report_id` - The cost report ID from the completed generation task
358 ///
359 /// # Returns
360 /// The raw bytes of the cost report file (CSV or JSON depending on request)
361 ///
362 /// # Example
363 /// ```no_run
364 /// # use redis_cloud::{CloudClient, CostReportHandler};
365 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
366 /// # let client = CloudClient::builder().api_key("k").api_secret("s").build()?;
367 /// let handler = CostReportHandler::new(client);
368 /// let report = handler.download_cost_report("cost-report-12345").await?;
369 /// std::fs::write("cost-report.csv", report)?;
370 /// # Ok(())
371 /// # }
372 /// ```
373 pub async fn download_cost_report(&self, cost_report_id: &str) -> Result<Vec<u8>> {
374 self.client
375 .get_bytes(&format!("/cost-report/{}", cost_report_id))
376 .await
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_cost_report_request_builder() {
386 let request = CostReportCreateRequest::builder()
387 .start_date("2025-01-01")
388 .end_date("2025-01-31")
389 .format(CostReportFormat::Csv)
390 .subscription_ids(vec![123, 456])
391 .regions(vec!["us-east-1".to_string()])
392 .tag("env", "prod")
393 .build();
394
395 assert_eq!(request.start_date, "2025-01-01");
396 assert_eq!(request.end_date, "2025-01-31");
397 assert_eq!(request.format, Some(CostReportFormat::Csv));
398 assert_eq!(request.subscription_ids, Some(vec![123, 456]));
399 assert_eq!(request.regions, Some(vec!["us-east-1".to_string()]));
400 assert!(request.tags.is_some());
401 let tags = request.tags.unwrap();
402 assert_eq!(tags.len(), 1);
403 assert_eq!(tags[0].key, "env");
404 assert_eq!(tags[0].value, "prod");
405 }
406
407 #[test]
408 fn test_cost_report_request_simple() {
409 let request = CostReportCreateRequest::new("2025-01-01", "2025-01-31");
410 assert_eq!(request.start_date, "2025-01-01");
411 assert_eq!(request.end_date, "2025-01-31");
412 assert!(request.format.is_none());
413 }
414
415 #[test]
416 fn test_cost_report_format_display() {
417 assert_eq!(CostReportFormat::Csv.to_string(), "csv");
418 assert_eq!(CostReportFormat::Json.to_string(), "json");
419 }
420
421 #[test]
422 fn test_subscription_type_display() {
423 assert_eq!(SubscriptionType::Pro.to_string(), "pro");
424 assert_eq!(SubscriptionType::Essentials.to_string(), "essentials");
425 }
426
427 #[test]
428 fn test_tag_creation() {
429 let tag = Tag::new("environment", "production");
430 assert_eq!(tag.key, "environment");
431 assert_eq!(tag.value, "production");
432 }
433
434 #[test]
435 fn test_request_serialization() {
436 let request = CostReportCreateRequest::builder()
437 .start_date("2025-01-01")
438 .end_date("2025-01-31")
439 .format(CostReportFormat::Json)
440 .subscription_type(SubscriptionType::Pro)
441 .build();
442
443 let json = serde_json::to_value(&request).unwrap();
444 assert_eq!(json["startDate"], "2025-01-01");
445 assert_eq!(json["endDate"], "2025-01-31");
446 assert_eq!(json["format"], "json");
447 assert_eq!(json["subscriptionType"], "pro");
448 }
449}