Skip to main content

namecheap_client/
users.rs

1//! The `namecheap.users` command namespace.
2
3use serde::Deserialize;
4
5use crate::client::Client;
6use crate::error::Result;
7use crate::response::de_opt_from_str;
8
9/// Accessor for `namecheap.users.*` commands, returned by
10/// [`Client::users`](crate::Client::users).
11#[derive(Debug, Clone, Copy)]
12pub struct Users<'a> {
13    client: &'a Client,
14}
15
16impl<'a> Users<'a> {
17    pub(crate) fn new(client: &'a Client) -> Self {
18        Self { client }
19    }
20
21    /// Returns the account balances (`namecheap.users.getBalances`).
22    ///
23    /// # Example
24    ///
25    /// ```no_run
26    /// # async fn run(client: namecheap_client::Client) -> Result<(), namecheap_client::Error> {
27    /// let balances = client.users().get_balances().await?;
28    /// println!("{} {}", balances.available_balance, balances.currency);
29    /// # Ok(())
30    /// # }
31    /// ```
32    ///
33    /// # Errors
34    ///
35    /// Returns an [`Error`](crate::Error) on transport failure, an API error
36    /// response, or a decode failure.
37    pub async fn get_balances(&self) -> Result<Balances> {
38        let payload: BalancesPayload = self
39            .client
40            .send("namecheap.users.getBalances", Vec::new())
41            .await?;
42        Ok(payload.result)
43    }
44
45    /// Looks up product pricing (`namecheap.users.getPricing`).
46    ///
47    /// This is a free, read-only call. For domain prices, build the request with
48    /// [`PricingRequest::domains`] and narrow it by action and TLD. The response
49    /// is nested (product type, then category, then product, then per-duration
50    /// prices); use [`PricingResult::prices`] to flatten it to the price entries.
51    ///
52    /// Note this returns *standard* TLD pricing. Per-name premium prices come
53    /// from [`domains().check()`](crate::domains::Domains::check) instead.
54    ///
55    /// # Example
56    ///
57    /// ```no_run
58    /// # async fn run(client: namecheap_client::Client) -> Result<(), namecheap_client::Error> {
59    /// use namecheap_client::PricingRequest;
60    /// let result = client
61    ///     .users()
62    ///     .get_pricing(&PricingRequest::domains().action("REGISTER").tld("com"))
63    ///     .await?;
64    /// for price in result.prices() {
65    ///     println!("{} {}: {} {}", price.duration, price.duration_type, price.price, price.currency);
66    /// }
67    /// # Ok(())
68    /// # }
69    /// ```
70    ///
71    /// # Errors
72    ///
73    /// Returns an [`Error`](crate::Error) on transport failure, an API error
74    /// response, or a decode failure.
75    pub async fn get_pricing(&self, request: &PricingRequest) -> Result<PricingResult> {
76        let payload: GetPricingPayload = self
77            .client
78            .send("namecheap.users.getPricing", request.to_params())
79            .await?;
80        Ok(PricingResult {
81            product_types: payload.result.product_types,
82        })
83    }
84}
85
86#[derive(Debug, Deserialize)]
87struct BalancesPayload {
88    #[serde(rename = "UserGetBalancesResult")]
89    result: Balances,
90}
91
92/// Account balances returned by [`Users::get_balances`].
93///
94/// Amounts are parsed into [`f64`]. Because the API returns them as decimal
95/// strings, treat these values as display figures rather than as inputs to
96/// exact financial arithmetic.
97#[derive(Debug, Clone, Deserialize)]
98#[non_exhaustive]
99pub struct Balances {
100    /// The account currency (for example `"USD"`).
101    #[serde(rename = "@Currency")]
102    pub currency: String,
103    /// The balance available to spend.
104    #[serde(rename = "@AvailableBalance")]
105    pub available_balance: f64,
106    /// The total account balance.
107    #[serde(rename = "@AccountBalance")]
108    pub account_balance: f64,
109    /// The amount earned (for reseller/affiliate accounts).
110    #[serde(rename = "@EarnedAmount")]
111    pub earned_amount: f64,
112    /// The amount that can currently be withdrawn.
113    #[serde(rename = "@WithdrawableAmount")]
114    pub withdrawable_amount: f64,
115    /// The funds required to cover upcoming auto-renewals.
116    #[serde(rename = "@FundsRequiredForAutoRenew")]
117    pub funds_required_for_auto_renew: f64,
118}
119
120/// A request for [`Users::get_pricing`].
121///
122/// For domain prices, start from [`PricingRequest::domains`] and narrow with
123/// [`action`](Self::action) and [`tld`](Self::tld).
124#[derive(Debug, Clone)]
125pub struct PricingRequest {
126    /// The product type (`"DOMAIN"`, `"SSLCERTIFICATE"`, or `"WHOISGUARD"`).
127    pub product_type: String,
128    /// The product category (for domains this is `"DOMAINS"`).
129    pub product_category: Option<String>,
130    /// The action to price (`"REGISTER"`, `"RENEW"`, `"TRANSFER"`, `"REACTIVATE"`).
131    /// When omitted, all actions are returned.
132    pub action_name: Option<String>,
133    /// The specific product, for example a TLD like `"com"`. When omitted, all
134    /// products are returned (which can be a large response).
135    pub product_name: Option<String>,
136    /// An optional promotion code to apply.
137    pub promotion_code: Option<String>,
138}
139
140impl PricingRequest {
141    /// A request for domain pricing (`ProductType=DOMAIN`).
142    #[must_use]
143    pub fn domains() -> Self {
144        Self {
145            product_type: "DOMAIN".to_owned(),
146            product_category: Some("DOMAINS".to_owned()),
147            action_name: None,
148            product_name: None,
149            promotion_code: None,
150        }
151    }
152
153    /// A request for an arbitrary product type.
154    pub fn new(product_type: impl Into<String>) -> Self {
155        Self {
156            product_type: product_type.into(),
157            product_category: None,
158            action_name: None,
159            product_name: None,
160            promotion_code: None,
161        }
162    }
163
164    /// Narrows to a single action (`"REGISTER"`, `"RENEW"`, ...).
165    #[must_use]
166    pub fn action(mut self, action: impl Into<String>) -> Self {
167        self.action_name = Some(action.into());
168        self
169    }
170
171    /// Narrows to a single TLD or product (for example `"com"`).
172    #[must_use]
173    pub fn tld(mut self, tld: impl Into<String>) -> Self {
174        self.product_name = Some(tld.into());
175        self
176    }
177
178    /// Sets the product category explicitly.
179    #[must_use]
180    pub fn category(mut self, category: impl Into<String>) -> Self {
181        self.product_category = Some(category.into());
182        self
183    }
184
185    /// Applies a promotion code.
186    #[must_use]
187    pub fn promotion_code(mut self, code: impl Into<String>) -> Self {
188        self.promotion_code = Some(code.into());
189        self
190    }
191
192    fn to_params(&self) -> Vec<(String, String)> {
193        let mut params = vec![("ProductType".to_owned(), self.product_type.clone())];
194        if let Some(category) = &self.product_category {
195            params.push(("ProductCategory".to_owned(), category.clone()));
196        }
197        if let Some(action) = &self.action_name {
198            params.push(("ActionName".to_owned(), action.clone()));
199        }
200        if let Some(product) = &self.product_name {
201            params.push(("ProductName".to_owned(), product.clone()));
202        }
203        if let Some(code) = &self.promotion_code {
204            params.push(("PromotionCode".to_owned(), code.clone()));
205        }
206        params
207    }
208}
209
210#[derive(Debug, Deserialize)]
211struct GetPricingPayload {
212    #[serde(rename = "UserGetPricingResult", default)]
213    result: UserGetPricingResult,
214}
215
216#[derive(Debug, Default, Deserialize)]
217struct UserGetPricingResult {
218    #[serde(rename = "ProductType", default)]
219    product_types: Vec<ProductTypePricing>,
220}
221
222/// Pricing returned by [`Users::get_pricing`].
223#[derive(Debug, Clone)]
224#[non_exhaustive]
225pub struct PricingResult {
226    /// The product types in the response (for a narrowed domain query, usually
227    /// just one).
228    pub product_types: Vec<ProductTypePricing>,
229}
230
231impl PricingResult {
232    /// Flattens the nested response into the individual [`Price`] entries, which
233    /// is usually what you want once the request is narrowed to one TLD and
234    /// action (you get one entry per available duration).
235    #[must_use]
236    pub fn prices(&self) -> Vec<&Price> {
237        self.product_types
238            .iter()
239            .flat_map(|product_type| &product_type.categories)
240            .flat_map(|category| &category.products)
241            .flat_map(|product| &product.prices)
242            .collect()
243    }
244}
245
246/// Pricing grouped under a product type (for example `"domains"`).
247#[derive(Debug, Clone, Deserialize)]
248#[non_exhaustive]
249pub struct ProductTypePricing {
250    /// The product type name.
251    #[serde(rename = "@Name")]
252    pub name: String,
253    /// The categories under this product type.
254    #[serde(rename = "ProductCategory", default)]
255    pub categories: Vec<ProductCategoryPricing>,
256}
257
258/// Pricing grouped under a category (for example `"register"` or `"renew"`).
259#[derive(Debug, Clone, Deserialize)]
260#[non_exhaustive]
261pub struct ProductCategoryPricing {
262    /// The category name.
263    #[serde(rename = "@Name")]
264    pub name: String,
265    /// The products under this category.
266    #[serde(rename = "Product", default)]
267    pub products: Vec<ProductPricing>,
268}
269
270/// Pricing for a single product (for domains, a TLD such as `"com"`).
271#[derive(Debug, Clone, Deserialize)]
272#[non_exhaustive]
273pub struct ProductPricing {
274    /// The product name (a TLD, for domain pricing).
275    #[serde(rename = "@Name")]
276    pub name: String,
277    /// The price for each available duration.
278    #[serde(rename = "Price", default)]
279    pub prices: Vec<Price>,
280}
281
282/// A single price entry for one duration of one product.
283#[derive(Debug, Clone, Deserialize)]
284#[non_exhaustive]
285pub struct Price {
286    /// The number of duration units (for example `1`).
287    #[serde(rename = "@Duration")]
288    pub duration: u32,
289    /// The duration unit (for example `"YEAR"`).
290    #[serde(rename = "@DurationType")]
291    pub duration_type: String,
292    /// The price for this duration.
293    #[serde(rename = "@Price")]
294    pub price: f64,
295    /// The standard (list) price.
296    #[serde(rename = "@RegularPrice")]
297    pub regular_price: f64,
298    /// The price for your account (may reflect account-specific rates).
299    #[serde(rename = "@YourPrice")]
300    pub your_price: f64,
301    /// The promotional price, when a promotion applies.
302    #[serde(
303        rename = "@PromotionPrice",
304        default,
305        deserialize_with = "de_opt_from_str"
306    )]
307    pub promotion_price: Option<f64>,
308    /// Any additional cost, such as the ICANN fee.
309    #[serde(
310        rename = "@AdditionalCost",
311        default,
312        deserialize_with = "de_opt_from_str"
313    )]
314    pub additional_cost: Option<f64>,
315    /// The currency (for example `"USD"`).
316    #[serde(rename = "@Currency")]
317    pub currency: String,
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use reqwest::StatusCode;
324
325    #[test]
326    fn parses_balances_response() {
327        let body = r#"<?xml version="1.0" encoding="utf-8"?>
328        <ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
329          <Errors />
330          <CommandResponse Type="namecheap.users.getBalances">
331            <UserGetBalancesResult Currency="USD" AvailableBalance="4932.96" AccountBalance="4932.96" EarnedAmount="381.30" WithdrawableAmount="1500.00" FundsRequiredForAutoRenew="0.00" />
332          </CommandResponse>
333        </ApiResponse>"#;
334
335        let payload: BalancesPayload = crate::response::parse(StatusCode::OK, body).unwrap();
336        let balances = payload.result;
337        assert_eq!(balances.currency, "USD");
338        assert_eq!(balances.available_balance, 4932.96);
339        assert_eq!(balances.earned_amount, 381.30);
340        assert_eq!(balances.funds_required_for_auto_renew, 0.0);
341    }
342
343    #[test]
344    fn parses_get_pricing_response() {
345        let body = r#"<ApiResponse Status="OK" xmlns="http://api.namecheap.com/xml.response">
346          <Errors />
347          <CommandResponse Type="namecheap.users.getPricing">
348            <UserGetPricingResult>
349              <ProductType Name="domains">
350                <ProductCategory Name="register">
351                  <Product Name="com">
352                    <Price Duration="1" DurationType="YEAR" Price="13.98" RegularPrice="13.98" YourPrice="13.98" AdditionalCost="0.20" PromotionPrice="0.0" Currency="USD" />
353                    <Price Duration="2" DurationType="YEAR" Price="26.26" RegularPrice="13.98" YourPrice="26.26" AdditionalCost="0.20" PromotionPrice="0.0" Currency="USD" />
354                  </Product>
355                </ProductCategory>
356              </ProductType>
357            </UserGetPricingResult>
358          </CommandResponse>
359        </ApiResponse>"#;
360
361        let payload: GetPricingPayload = crate::response::parse(StatusCode::OK, body).unwrap();
362        let result = PricingResult {
363            product_types: payload.result.product_types,
364        };
365        assert_eq!(result.product_types.len(), 1);
366        assert_eq!(result.product_types[0].name, "domains");
367        assert_eq!(result.product_types[0].categories[0].name, "register");
368        assert_eq!(
369            result.product_types[0].categories[0].products[0].name,
370            "com"
371        );
372
373        let prices = result.prices();
374        assert_eq!(prices.len(), 2);
375        assert_eq!(prices[0].duration, 1);
376        assert_eq!(prices[0].price, 13.98);
377        assert_eq!(prices[0].regular_price, 13.98);
378        assert_eq!(prices[0].additional_cost, Some(0.20));
379        assert_eq!(prices[0].currency, "USD");
380        assert_eq!(prices[1].duration, 2);
381        assert_eq!(prices[1].price, 26.26);
382    }
383
384    #[test]
385    fn pricing_request_builds_params() {
386        let params = PricingRequest::domains()
387            .action("REGISTER")
388            .tld("com")
389            .to_params();
390        let get = |key: &str| {
391            params
392                .iter()
393                .find(|(k, _)| k == key)
394                .map(|(_, v)| v.as_str())
395        };
396        assert_eq!(get("ProductType"), Some("DOMAIN"));
397        assert_eq!(get("ProductCategory"), Some("DOMAINS"));
398        assert_eq!(get("ActionName"), Some("REGISTER"));
399        assert_eq!(get("ProductName"), Some("com"));
400    }
401}