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}