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}