cbilling 0.2.0

Multi-cloud billing SDK for Rust — query billing data from AWS, GCP, Aliyun, Tencent Cloud, Volcengine, UCloud, Cloudflare
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
// Copyright 2025 OpenObserve Inc.

//! Aliyun (Alibaba Cloud) Billing Provider
//!
//! This module implements billing integration with Aliyun using their REST API

use std::collections::BTreeMap;

use chrono::Utc;
use hmac::{Hmac, Mac};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::super::{BillingError, Result};

type HmacSha1 = Hmac<sha1::Sha1>;

const ALIYUN_BILLING_ENDPOINT: &str = "https://business.aliyuncs.com";

/// Aliyun Billing Client
pub struct AliyunBillingClient {
    access_key_id: String,
    access_key_secret: String,
    http_client: Client,
}

/// Aliyun account-level bill API response.
#[derive(Debug, Serialize, Deserialize)]
pub struct AliyunAccountBillResponse {
    #[serde(rename = "Success")]
    pub success: bool,
    #[serde(rename = "Code")]
    pub code: String,
    #[serde(rename = "Message")]
    pub message: String,
    #[serde(rename = "Data")]
    pub data: Option<AliyunAccountBillData>,
    #[serde(rename = "RequestId")]
    pub request_id: String,
}

/// Paginated account bill data returned by the Aliyun API.
#[derive(Debug, Serialize, Deserialize)]
pub struct AliyunAccountBillData {
    #[serde(rename = "BillingCycle")]
    pub billing_cycle: String,
    #[serde(rename = "AccountID")]
    pub account_id: String,
    #[serde(rename = "AccountName")]
    pub account_name: String,
    #[serde(rename = "TotalCount")]
    pub total_count: i32,
    #[serde(rename = "PageNum")]
    pub page_num: i32,
    #[serde(rename = "PageSize")]
    pub page_size: i32,
    #[serde(rename = "Items")]
    pub items: Option<AliyunBillItems>,
}

/// Wrapper for a list of Aliyun account bill items.
#[derive(Debug, Serialize, Deserialize)]
pub struct AliyunBillItems {
    #[serde(rename = "Item")]
    pub item: Vec<AliyunBillItem>,
}

/// A single account-level bill line item from Aliyun.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AliyunBillItem {
    #[serde(rename = "BillingDate")]
    pub billing_date: Option<String>,
    #[serde(rename = "ProductCode")]
    pub product_code: Option<String>,
    #[serde(rename = "ProductName")]
    pub product_name: Option<String>,
    #[serde(rename = "SubscriptionType")]
    pub subscription_type: Option<String>,
    #[serde(rename = "PretaxAmount")]
    pub pretax_amount: Option<f64>,
    #[serde(rename = "PretaxGrossAmount")]
    pub pretax_gross_amount: Option<f64>,
    #[serde(rename = "Currency")]
    pub currency: Option<String>,
    #[serde(rename = "AdjustAmount")]
    pub adjust_amount: Option<f64>,
    #[serde(rename = "InvoiceDiscount")]
    pub invoice_discount: Option<f64>,
    #[serde(rename = "DeductedByCashCoupons")]
    pub deducted_by_cash_coupons: Option<f64>,
    #[serde(rename = "DeductedByCoupons")]
    pub deducted_by_coupons: Option<f64>,
    #[serde(rename = "DeductedByPrepaidCard")]
    pub deducted_by_prepaid_card: Option<f64>,
    #[serde(rename = "PaymentAmount")]
    pub payment_amount: Option<f64>,
    #[serde(rename = "OutstandingAmount")]
    pub outstanding_amount: Option<f64>,
}

/// Aliyun instance-level bill API response.
#[derive(Debug, Serialize, Deserialize)]
pub struct AliyunInstanceBillResponse {
    #[serde(rename = "Success")]
    pub success: bool,
    #[serde(rename = "Code")]
    pub code: String,
    #[serde(rename = "Message")]
    pub message: String,
    #[serde(rename = "Data")]
    pub data: Option<AliyunInstanceBillData>,
    #[serde(rename = "RequestId")]
    pub request_id: String,
}

/// Paginated instance bill data returned by the Aliyun API.
#[derive(Debug, Serialize, Deserialize)]
pub struct AliyunInstanceBillData {
    #[serde(rename = "BillingCycle")]
    pub billing_cycle: String,
    #[serde(rename = "AccountID")]
    pub account_id: String,
    #[serde(rename = "TotalCount")]
    pub total_count: i32,
    #[serde(rename = "PageNum")]
    pub page_num: i32,
    #[serde(rename = "PageSize")]
    pub page_size: i32,
    #[serde(rename = "Items")]
    pub items: Option<AliyunInstanceBillItems>,
}

/// Wrapper for a list of Aliyun instance bill items.
#[derive(Debug, Serialize, Deserialize)]
pub struct AliyunInstanceBillItems {
    #[serde(rename = "Item")]
    pub item: Vec<AliyunInstanceBillItem>,
}

/// A single instance-level bill line item from Aliyun.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AliyunInstanceBillItem {
    #[serde(rename = "InstanceID")]
    pub instance_id: Option<String>,
    #[serde(rename = "ProductCode")]
    pub product_code: Option<String>,
    #[serde(rename = "ProductName")]
    pub product_name: Option<String>,
    #[serde(rename = "Region")]
    pub region: Option<String>,
    #[serde(rename = "SubscriptionType")]
    pub subscription_type: Option<String>,
    #[serde(rename = "PretaxAmount")]
    pub pretax_amount: Option<f64>,
    #[serde(rename = "PretaxGrossAmount")]
    pub pretax_gross_amount: Option<f64>,
    #[serde(rename = "Currency")]
    pub currency: Option<String>,
    #[serde(rename = "PaymentAmount")]
    pub payment_amount: Option<f64>,
}

impl AliyunBillingClient {
    /// Creates a new Aliyun billing client with the given credentials.
    pub fn new(access_key_id: String, access_key_secret: String) -> Self {
        Self {
            access_key_id,
            access_key_secret,
            http_client: Client::new(),
        }
    }

    /// Generate Aliyun API signature (Signature v1)
    fn generate_signature(&self, string_to_sign: &str) -> Result<String> {
        use base64::Engine;
        let mut mac =
            HmacSha1::new_from_slice(format!("{}&", self.access_key_secret).as_bytes())
                .map_err(|e| BillingError::ServiceError(format!("Failed to create HMAC: {}", e)))?;

        mac.update(string_to_sign.as_bytes());
        let result = mac.finalize();
        Ok(base64::engine::general_purpose::STANDARD.encode(result.into_bytes()))
    }

    /// Build common query parameters for Aliyun API
    fn build_common_params(&self, action: &str) -> BTreeMap<String, String> {
        let mut params = BTreeMap::new();

        params.insert("AccessKeyId".to_string(), self.access_key_id.clone());
        params.insert("Action".to_string(), action.to_string());
        params.insert("Format".to_string(), "JSON".to_string());
        params.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
        // Generate nonce using timestamp to avoid uuid dependency
        params.insert(
            "SignatureNonce".to_string(),
            format!(
                "{}-{}",
                Utc::now().timestamp_nanos_opt().unwrap_or(0),
                std::process::id()
            ),
        );
        params.insert("SignatureVersion".to_string(), "1.0".to_string());
        params.insert(
            "Timestamp".to_string(),
            Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
        );
        params.insert("Version".to_string(), "2017-12-14".to_string());

        params
    }

    /// Canonicalize query string for signature
    fn canonicalize_query_string(&self, params: &BTreeMap<String, String>) -> String {
        params
            .iter()
            .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
            .collect::<Vec<String>>()
            .join("&")
    }

    /// Call Aliyun API
    async fn call_api(&self, action: &str, mut params: BTreeMap<String, String>) -> Result<Value> {
        // Add common parameters
        let common_params = self.build_common_params(action);
        for (k, v) in common_params {
            params.insert(k, v);
        }

        // Build canonicalized query string
        let canonicalized_query_string = self.canonicalize_query_string(&params);

        // Build string to sign
        let string_to_sign = format!(
            "GET&{}&{}",
            percent_encode("/"),
            percent_encode(&canonicalized_query_string)
        );

        // Generate signature
        let signature = self.generate_signature(&string_to_sign)?;
        params.insert("Signature".to_string(), signature);

        // Build final URL
        let query_string = params
            .iter()
            .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
            .collect::<Vec<String>>()
            .join("&");

        let url = format!("{}/?{}", ALIYUN_BILLING_ENDPOINT, query_string);

        // Make request
        let response = self
            .http_client
            .get(&url)
            .send()
            .await
            .map_err(|e| BillingError::ServiceError(format!("HTTP request failed: {}", e)))?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            return Err(BillingError::ServiceError(format!(
                "Aliyun API error: {} - {}",
                status, body
            )));
        }

        let json_response = response
            .json::<Value>()
            .await
            .map_err(|e| BillingError::ServiceError(format!("Failed to parse response: {}", e)))?;

        Ok(json_response)
    }

    /// Query account bill
    pub async fn query_account_bill(
        &self,
        billing_cycle: &str,
        page_num: Option<i32>,
        page_size: Option<i32>,
    ) -> Result<AliyunAccountBillResponse> {
        tracing::info!("Querying Aliyun billing for cycle: {}", billing_cycle);

        let mut params = BTreeMap::new();
        params.insert("BillingCycle".to_string(), billing_cycle.to_string());

        if let Some(num) = page_num {
            params.insert("PageNum".to_string(), num.to_string());
        }

        if let Some(size) = page_size {
            params.insert("PageSize".to_string(), size.to_string());
        }

        let response = self.call_api("QueryAccountBill", params).await?;

        tracing::debug!(
            "Aliyun API response: {}",
            serde_json::to_string_pretty(&response).unwrap_or_default()
        );

        let result: AliyunAccountBillResponse =
            serde_json::from_value(response.clone()).map_err(|e| {
                BillingError::ServiceError(format!("Failed to parse account bill: {}", e))
            })?;

        tracing::info!(
            "Aliyun response - Success: {}, Code: {}, Has data: {}",
            result.success,
            result.code,
            result.data.is_some()
        );

        if let Some(ref data) = result.data {
            tracing::info!(
                "Aliyun billing data - Total count: {}, Items: {}",
                data.total_count,
                data.items.as_ref().map(|i| i.item.len()).unwrap_or(0)
            );

            if let Some(ref items) = data.items {
                if !items.item.is_empty() {
                    let first_item = &items.item[0];
                    tracing::debug!(
                        "Sample Aliyun item - ProductName: {:?}, ProductCode: {:?}, Amount: {:?}",
                        first_item.product_name,
                        first_item.product_code,
                        first_item.pretax_amount
                    );
                }
            }
        }

        Ok(result)
    }

    /// Query instance-level bill details.
    pub async fn query_instance_bill(
        &self,
        billing_cycle: &str,
        page_num: Option<i32>,
        page_size: Option<i32>,
        product_code: Option<&str>,
    ) -> Result<AliyunInstanceBillResponse> {
        let mut params = BTreeMap::new();
        params.insert("BillingCycle".to_string(), billing_cycle.to_string());

        if let Some(num) = page_num {
            params.insert("PageNum".to_string(), num.to_string());
        }

        if let Some(size) = page_size {
            params.insert("PageSize".to_string(), size.to_string());
        }

        if let Some(code) = product_code {
            params.insert("ProductCode".to_string(), code.to_string());
        }

        let response = self.call_api("QueryInstanceBill", params).await?;

        serde_json::from_value(response).map_err(|e| {
            BillingError::ServiceError(format!("Failed to parse instance bill: {}", e))
        })
    }

    /// Test credentials
    pub async fn test_credentials(&self) -> Result<bool> {
        // Use current month for testing
        let billing_cycle = Utc::now().format("%Y-%m").to_string();
        match self
            .query_account_bill(&billing_cycle, Some(1), Some(1))
            .await
        {
            Ok(response) => Ok(response.success),
            Err(_) => Ok(false),
        }
    }
}

/// Percent-encode string for URL (RFC 3986)
fn percent_encode(input: &str) -> String {
    urlencoding::encode(input)
        .replace("+", "%20")
        .replace("*", "%2A")
        .replace("%7E", "~")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_client_creation() {
        let client = AliyunBillingClient::new("test_id".to_string(), "test_secret".to_string());
        assert_eq!(client.access_key_id, "test_id");
    }

    #[test]
    fn test_percent_encode() {
        assert_eq!(percent_encode("hello world"), "hello%20world");
        assert_eq!(percent_encode("test&value=1"), "test%26value%3D1");
    }
}