Skip to main content

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
174impl CostReportCreateRequest {
175    /// Create a new cost report request builder
176    #[must_use]
177    pub fn builder() -> CostReportCreateRequestBuilder {
178        CostReportCreateRequestBuilder::default()
179    }
180
181    /// Create a simple request with just date range
182    pub fn new(start_date: impl Into<String>, end_date: impl Into<String>) -> Self {
183        Self {
184            start_date: start_date.into(),
185            end_date: end_date.into(),
186            ..Default::default()
187        }
188    }
189}
190
191/// Builder for `CostReportCreateRequest`
192#[derive(Debug, Clone, Default)]
193pub struct CostReportCreateRequestBuilder {
194    start_date: Option<String>,
195    end_date: Option<String>,
196    format: Option<CostReportFormat>,
197    subscription_ids: Option<Vec<i32>>,
198    database_ids: Option<Vec<i32>>,
199    subscription_type: Option<SubscriptionType>,
200    regions: Option<Vec<String>>,
201    tags: Option<Vec<Tag>>,
202}
203
204impl CostReportCreateRequestBuilder {
205    /// Set the start date (required, YYYY-MM-DD format)
206    #[must_use]
207    pub fn start_date(mut self, date: impl Into<String>) -> Self {
208        self.start_date = Some(date.into());
209        self
210    }
211
212    /// Set the end date (required, YYYY-MM-DD format)
213    #[must_use]
214    pub fn end_date(mut self, date: impl Into<String>) -> Self {
215        self.end_date = Some(date.into());
216        self
217    }
218
219    /// Set the output format
220    #[must_use]
221    pub fn format(mut self, format: CostReportFormat) -> Self {
222        self.format = Some(format);
223        self
224    }
225
226    /// Filter by subscription IDs
227    #[must_use]
228    pub fn subscription_ids(mut self, ids: Vec<i32>) -> Self {
229        self.subscription_ids = Some(ids);
230        self
231    }
232
233    /// Filter by database IDs
234    #[must_use]
235    pub fn database_ids(mut self, ids: Vec<i32>) -> Self {
236        self.database_ids = Some(ids);
237        self
238    }
239
240    /// Filter by subscription type
241    #[must_use]
242    pub fn subscription_type(mut self, sub_type: SubscriptionType) -> Self {
243        self.subscription_type = Some(sub_type);
244        self
245    }
246
247    /// Filter by regions
248    #[must_use]
249    pub fn regions(mut self, regions: Vec<String>) -> Self {
250        self.regions = Some(regions);
251        self
252    }
253
254    /// Filter by tags
255    #[must_use]
256    pub fn tags(mut self, tags: Vec<Tag>) -> Self {
257        self.tags = Some(tags);
258        self
259    }
260
261    /// Add a single tag filter
262    #[must_use]
263    pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
264        let tag = Tag::new(key, value);
265        match &mut self.tags {
266            Some(tags) => tags.push(tag),
267            None => self.tags = Some(vec![tag]),
268        }
269        self
270    }
271
272    /// Build the request
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if `start_date` or `end_date` is not set.
277    pub fn build(self) -> Result<CostReportCreateRequest> {
278        let start_date = self
279            .start_date
280            .ok_or_else(|| crate::CloudError::BadRequest {
281                message: "start_date is required".to_string(),
282            })?;
283        let end_date = self.end_date.ok_or_else(|| crate::CloudError::BadRequest {
284            message: "end_date is required".to_string(),
285        })?;
286
287        Ok(CostReportCreateRequest {
288            start_date,
289            end_date,
290            format: self.format,
291            subscription_ids: self.subscription_ids,
292            database_ids: self.database_ids,
293            subscription_type: self.subscription_type,
294            regions: self.regions,
295            tags: self.tags,
296        })
297    }
298}
299
300// ============================================================================
301// Handler
302// ============================================================================
303
304/// Handler for cost report operations
305///
306/// Provides methods to generate and download cost reports in FOCUS format.
307pub struct CostReportHandler {
308    client: CloudClient,
309}
310
311impl CostReportHandler {
312    /// Create a new handler
313    #[must_use]
314    pub fn new(client: CloudClient) -> Self {
315        Self { client }
316    }
317
318    /// Generate a cost report (Beta)
319    ///
320    /// Generates a cost report in FOCUS format for the specified time period
321    /// and filters. The maximum date range is 40 days.
322    ///
323    /// This is an asynchronous operation. The returned `TaskStateUpdate` contains
324    /// a `task_id` that can be used to track progress. Once complete, the task
325    /// response will contain the costReportId needed to download the report.
326    ///
327    /// POST /cost-report
328    ///
329    /// # Arguments
330    /// * `request` - The cost report generation request with date range and filters
331    ///
332    /// # Returns
333    /// A `TaskStateUpdate` with the task ID for tracking the generation
334    ///
335    /// # Example
336    /// ```no_run
337    /// # use redis_cloud::{CloudClient, CostReportHandler, CostReportCreateRequest};
338    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
339    /// # let client = CloudClient::builder().api_key("k").api_secret("s").build()?;
340    /// let handler = CostReportHandler::new(client);
341    /// let request = CostReportCreateRequest::new("2025-01-01", "2025-01-31");
342    /// let task = handler.generate_cost_report(request).await?;
343    /// println!("Task ID: {:?}", task.task_id);
344    /// # Ok(())
345    /// # }
346    /// ```
347    pub async fn generate_cost_report(
348        &self,
349        request: CostReportCreateRequest,
350    ) -> Result<TaskStateUpdate> {
351        self.client.post("/cost-report", &request).await
352    }
353
354    /// Generate a cost report and return raw JSON response
355    ///
356    /// POST /cost-report
357    pub async fn generate_cost_report_raw(
358        &self,
359        request: CostReportCreateRequest,
360    ) -> Result<Value> {
361        let body = serde_json::to_value(request).map_err(crate::CloudError::from)?;
362        self.client.post_raw("/cost-report", body).await
363    }
364
365    /// Download a generated cost report (Beta)
366    ///
367    /// Returns the generated cost report file in FOCUS format. The costReportId
368    /// is obtained from the task response after the generation task completes.
369    ///
370    /// GET /cost-report/{costReportId}
371    ///
372    /// # Arguments
373    /// * `cost_report_id` - The cost report ID from the completed generation task
374    ///
375    /// # Returns
376    /// The raw bytes of the cost report file (CSV or JSON depending on request)
377    ///
378    /// # Example
379    /// ```no_run
380    /// # use redis_cloud::{CloudClient, CostReportHandler};
381    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
382    /// # let client = CloudClient::builder().api_key("k").api_secret("s").build()?;
383    /// let handler = CostReportHandler::new(client);
384    /// let report = handler.download_cost_report("cost-report-12345").await?;
385    /// std::fs::write("cost-report.csv", report)?;
386    /// # Ok(())
387    /// # }
388    /// ```
389    pub async fn download_cost_report(&self, cost_report_id: &str) -> Result<Vec<u8>> {
390        self.client
391            .get_bytes(&format!("/cost-report/{cost_report_id}"))
392            .await
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_cost_report_request_builder() {
402        let request = CostReportCreateRequest::builder()
403            .start_date("2025-01-01")
404            .end_date("2025-01-31")
405            .format(CostReportFormat::Csv)
406            .subscription_ids(vec![123, 456])
407            .regions(vec!["us-east-1".to_string()])
408            .tag("env", "prod")
409            .build()
410            .expect("should build with all required fields");
411
412        assert_eq!(request.start_date, "2025-01-01");
413        assert_eq!(request.end_date, "2025-01-31");
414        assert_eq!(request.format, Some(CostReportFormat::Csv));
415        assert_eq!(request.subscription_ids, Some(vec![123, 456]));
416        assert_eq!(request.regions, Some(vec!["us-east-1".to_string()]));
417        assert!(request.tags.is_some());
418        let tags = request.tags.unwrap();
419        assert_eq!(tags.len(), 1);
420        assert_eq!(tags[0].key, "env");
421        assert_eq!(tags[0].value, "prod");
422    }
423
424    #[test]
425    fn test_cost_report_request_simple() {
426        let request = CostReportCreateRequest::new("2025-01-01", "2025-01-31");
427        assert_eq!(request.start_date, "2025-01-01");
428        assert_eq!(request.end_date, "2025-01-31");
429        assert!(request.format.is_none());
430    }
431
432    #[test]
433    fn test_cost_report_format_display() {
434        assert_eq!(CostReportFormat::Csv.to_string(), "csv");
435        assert_eq!(CostReportFormat::Json.to_string(), "json");
436    }
437
438    #[test]
439    fn test_subscription_type_display() {
440        assert_eq!(SubscriptionType::Pro.to_string(), "pro");
441        assert_eq!(SubscriptionType::Essentials.to_string(), "essentials");
442    }
443
444    #[test]
445    fn test_tag_creation() {
446        let tag = Tag::new("environment", "production");
447        assert_eq!(tag.key, "environment");
448        assert_eq!(tag.value, "production");
449    }
450
451    #[test]
452    fn test_request_serialization() {
453        let request = CostReportCreateRequest::builder()
454            .start_date("2025-01-01")
455            .end_date("2025-01-31")
456            .format(CostReportFormat::Json)
457            .subscription_type(SubscriptionType::Pro)
458            .build()
459            .expect("should build with all required fields");
460
461        let json = serde_json::to_value(&request).unwrap();
462        assert_eq!(json["startDate"], "2025-01-01");
463        assert_eq!(json["endDate"], "2025-01-31");
464        assert_eq!(json["format"], "json");
465        assert_eq!(json["subscriptionType"], "pro");
466    }
467
468    #[test]
469    fn test_builder_missing_start_date() {
470        let result = CostReportCreateRequest::builder()
471            .end_date("2025-01-31")
472            .build();
473
474        assert!(result.is_err());
475    }
476
477    #[test]
478    fn test_builder_missing_end_date() {
479        let result = CostReportCreateRequest::builder()
480            .start_date("2025-01-01")
481            .build();
482
483        assert!(result.is_err());
484    }
485}