Skip to main content

steam_user/services/
account.rs

1//! Account management services.
2
3use std::sync::OnceLock;
4
5use regex::Regex;
6use scraper::{Html, Selector};
7
8use crate::{
9    client::SteamUser,
10    endpoint::steam_endpoint,
11    error::SteamUserError,
12    types::{AccountDetails, PurchaseHistoryItem, RedeemWalletCodeResponse, TransactionId, WalletBalance},
13    utils::get_avatar_hash_from_url,
14};
15
16static SEL_BALANCE: OnceLock<Selector> = OnceLock::new();
17fn sel_balance() -> &'static Selector {
18    SEL_BALANCE.get_or_init(|| Selector::parse("#header_wallet_balance").expect("valid CSS selector"))
19}
20
21static SEL_TOOLTIP: OnceLock<Selector> = OnceLock::new();
22fn sel_tooltip() -> &'static Selector {
23    SEL_TOOLTIP.get_or_init(|| Selector::parse("span.tooltip").expect("valid CSS selector"))
24}
25
26static SEL_HELP_SPEND: OnceLock<Selector> = OnceLock::new();
27fn sel_help_spend() -> &'static Selector {
28    SEL_HELP_SPEND.get_or_init(|| Selector::parse(".help_event_limiteduser .help_event_limiteduser_spend").expect("valid CSS selector"))
29}
30
31static SEL_TITLE: OnceLock<Selector> = OnceLock::new();
32fn sel_title() -> &'static Selector {
33    SEL_TITLE.get_or_init(|| Selector::parse("title").expect("valid CSS selector"))
34}
35
36static SEL_WALLET_ROW: OnceLock<Selector> = OnceLock::new();
37fn sel_wallet_row() -> &'static Selector {
38    SEL_WALLET_ROW.get_or_init(|| Selector::parse(".wallet_table_row").expect("valid CSS selector"))
39}
40
41static SEL_WHT_DATE: OnceLock<Selector> = OnceLock::new();
42fn sel_wht_date() -> &'static Selector {
43    SEL_WHT_DATE.get_or_init(|| Selector::parse(".wht_date").expect("valid CSS selector"))
44}
45
46static SEL_WHT_TYPE: OnceLock<Selector> = OnceLock::new();
47fn sel_wht_type() -> &'static Selector {
48    SEL_WHT_TYPE.get_or_init(|| Selector::parse(".wht_type").expect("valid CSS selector"))
49}
50
51static SEL_WHT_ITEMS: OnceLock<Selector> = OnceLock::new();
52fn sel_wht_items() -> &'static Selector {
53    SEL_WHT_ITEMS.get_or_init(|| Selector::parse(".wht_items").expect("valid CSS selector"))
54}
55
56static SEL_WHT_TOTAL: OnceLock<Selector> = OnceLock::new();
57fn sel_wht_total() -> &'static Selector {
58    SEL_WHT_TOTAL.get_or_init(|| Selector::parse(".wht_total").expect("valid CSS selector"))
59}
60
61static SEL_WHT_BASE_PRICE: OnceLock<Selector> = OnceLock::new();
62fn sel_wht_base_price() -> &'static Selector {
63    SEL_WHT_BASE_PRICE.get_or_init(|| Selector::parse(".wht_base_price, .wht_base_price_discounted").expect("valid CSS selector"))
64}
65
66static SEL_WHT_TAX: OnceLock<Selector> = OnceLock::new();
67fn sel_wht_tax() -> &'static Selector {
68    SEL_WHT_TAX.get_or_init(|| Selector::parse(".wht_tax").expect("valid CSS selector"))
69}
70
71static SEL_WHT_SHIPPING: OnceLock<Selector> = OnceLock::new();
72fn sel_wht_shipping() -> &'static Selector {
73    SEL_WHT_SHIPPING.get_or_init(|| Selector::parse(".wht_shipping").expect("valid CSS selector"))
74}
75
76static SEL_WHT_WALLET_CHANGE: OnceLock<Selector> = OnceLock::new();
77fn sel_wht_wallet_change() -> &'static Selector {
78    SEL_WHT_WALLET_CHANGE.get_or_init(|| Selector::parse(".wht_wallet_change").expect("valid CSS selector"))
79}
80
81static SEL_WHT_WALLET: OnceLock<Selector> = OnceLock::new();
82fn sel_wht_wallet() -> &'static Selector {
83    SEL_WHT_WALLET.get_or_init(|| Selector::parse(".wht_wallet_balance").expect("valid CSS selector"))
84}
85
86static SEL_WTH_PAYMENT: OnceLock<Selector> = OnceLock::new();
87fn sel_wth_payment() -> &'static Selector {
88    SEL_WTH_PAYMENT.get_or_init(|| Selector::parse(".wth_payment").expect("valid CSS selector"))
89}
90
91static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
92fn sel_player_avatar_img() -> &'static Selector {
93    SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar img").expect("valid CSS selector"))
94}
95
96static RE_CURRENCY_END: OnceLock<Regex> = OnceLock::new();
97fn re_currency_end() -> &'static Regex {
98    RE_CURRENCY_END.get_or_init(|| Regex::new(r"([^\d.,\s].*)$").expect("valid regex"))
99}
100
101static RE_CURRENCY_START: OnceLock<Regex> = OnceLock::new();
102fn re_currency_start() -> &'static Regex {
103    RE_CURRENCY_START.get_or_init(|| Regex::new(r"^([^\d.,\s]+)").expect("valid regex"))
104}
105
106static RE_PENDING: OnceLock<Regex> = OnceLock::new();
107fn re_pending() -> &'static Regex {
108    RE_PENDING.get_or_init(|| Regex::new(r"Pending:\s*([\d.,]+[^\s]*)").expect("valid regex"))
109}
110
111static RE_TRANSID: OnceLock<Regex> = OnceLock::new();
112fn re_transid() -> &'static Regex {
113    RE_TRANSID.get_or_init(|| Regex::new(r"transid=(\d+)").expect("valid regex"))
114}
115
116/// Parse wallet balance fields out of any Steam Store HTML document.
117///
118/// Looks for `#header_wallet_balance` (present on every logged-in Store page),
119/// extracts the display text as `main_balance`, detects the currency symbol,
120/// and reads any pending balance from the tooltip child span.
121pub(crate) fn parse_wallet_balance(document: &Html) -> WalletBalance {
122    let mut main_balance = None;
123    let mut currency = None;
124    let mut pending = None;
125
126    if let Some(el) = document.select(sel_balance()).next() {
127        // Collect only direct text nodes — ignores the nested tooltip <span>
128        let text: String = el.children().filter_map(|n| n.value().as_text().map(|t| t.to_string())).collect::<String>().trim().to_string();
129
130        if !text.is_empty() {
131            // Currency symbol: non-digit/non-separator chars at start or end
132            if let Some(caps) = re_currency_end().captures(&text) {
133                currency = Some(caps[1].trim().to_string());
134            } else if let Some(caps) = re_currency_start().captures(&text) {
135                currency = Some(caps[1].trim().to_string());
136            }
137
138            main_balance = Some(text);
139        }
140
141        // Pending balance lives inside the tooltip child span
142        if let Some(tip) = el.select(sel_tooltip()).next() {
143            let tip_text = tip.text().collect::<String>();
144            if let Some(caps) = re_pending().captures(&tip_text) {
145                pending = Some(caps[1].to_string());
146            }
147        }
148    }
149
150    WalletBalance { main_balance, pending, currency }
151}
152
153impl SteamUser {
154    /// Retrieves the Steam Wallet balance(s) and account currency.
155    ///
156    /// Scrapes the Steam Community home page to extract the main wallet balance
157    /// and any pending balances.
158    ///
159    /// # Returns
160    ///
161    /// Returns a [`WalletBalance`] struct containing:
162    /// - `main_balance`: The current available balance (e.g., "$10.00").
163    /// - `pending`: Any pending balance awaiting verification.
164    /// - `currency`: The currency symbol or code extracted from the balance
165    ///   string.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`SteamUserError::Other("Not logged in")`] if the session is not
170    /// authenticated.
171    ///
172    /// # Example
173    ///
174    /// ```rust,no_run
175    /// # use steam_user::client::SteamUser;
176    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
177    /// let wallet = user.get_steam_wallet_balance().await?;
178    /// if let Some(balance) = wallet.main_balance {
179    ///     println!("Current balance: {}", balance);
180    /// }
181    /// # Ok(())
182    /// # }
183    /// ```
184    // delegates to `get_account_details` — no #[steam_endpoint]
185    #[tracing::instrument(skip(self))]
186    pub async fn get_steam_wallet_balance(&self) -> Result<WalletBalance, SteamUserError> {
187        let details = self.get_account_details().await?;
188        details.wallet_balance.ok_or_else(|| SteamUserError::Other("Wallet balance not found".into()))
189    }
190
191    /// Retrieves the total amount spent on Steam for the current account.
192    ///
193    /// Scrapes the Steam Help page to determine the lifetime spending on the
194    /// account. This is often used to check if an account is "limited"
195    /// (spent less than $5.00).
196    ///
197    /// # Returns
198    ///
199    /// Returns a `String` representing the total amount spent (e.g.,
200    /// "$150.42").
201    ///
202    /// # Example
203    ///
204    /// ```rust,no_run
205    /// # use steam_user::client::SteamUser;
206    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
207    /// let spent = user.get_amount_spent_on_steam().await?;
208    /// println!("Lifetime spend: {}", spent);
209    /// # Ok(())
210    /// # }
211    /// ```
212    #[steam_endpoint(GET, host = Help, path = "/en/", kind = Read)]
213    pub async fn get_amount_spent_on_steam(&self) -> Result<String, SteamUserError> {
214        let response = self.get_path("/en/").send().await?.text().await?;
215
216        let document = Html::parse_document(&response);
217
218        if let Some(el) = document.select(sel_help_spend()).next() {
219            let text = el.text().collect::<String>().trim().to_string();
220            // Clean space equivalent
221            let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
222
223            if text.starts_with("Amount Spent on Steam:") {
224                return Ok(text.replace("Amount Spent on Steam:", "").trim().to_string());
225            }
226        }
227
228        Err(SteamUserError::Other("Amount spent information not found on help page".into()))
229    }
230
231    /// Unlocks Steam Parental Controls using the provided PIN.
232    ///
233    /// Sends a POST request to `https://steamcommunity.com/parental/ajaxunlock`.
234    ///
235    /// # Arguments
236    ///
237    /// * `pin` - A 4-digit PIN code as a string.
238    ///
239    /// # Errors
240    ///
241    /// - Returns [`SteamUserError::Other("Incorrect PIN")`] if the PIN is
242    ///   wrong.
243    /// - Returns [`SteamUserError::Other("Too many invalid PIN attempts")`] if
244    ///   locked out.
245    ///
246    /// # Example
247    ///
248    /// ```rust,no_run
249    /// # use steam_user::client::SteamUser;
250    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
251    /// match user.parental_unlock("1234").await {
252    ///     Ok(_) => println!("Unlocked!"),
253    ///     Err(e) => eprintln!("Failed to unlock: {}", e),
254    /// }
255    /// # Ok(())
256    /// # }
257    /// ```
258    #[steam_endpoint(POST, host = Community, path = "/parental/ajaxunlock", kind = Auth)]
259    pub async fn parental_unlock(&self, pin: &str) -> Result<(), SteamUserError> {
260        let response: serde_json::Value = self.post_path("/parental/ajaxunlock").form(&[("pin", pin)]).send().await?.json().await?;
261
262        let result = Self::check_json_success(&response, "Failed to unlock parental controls");
263
264        match result {
265            Ok(_) => Ok(()),
266            Err(SteamUserError::EResult { code, .. }) => match code {
267                15 => Err(SteamUserError::Other("Incorrect PIN".into())),
268                25 => Err(SteamUserError::Other("Too many invalid PIN attempts".into())),
269                _ => Err(SteamUserError::from_eresult(code)),
270            },
271            Err(e) => Err(e),
272        }
273    }
274
275    /// Retrieves the Steam purchase history for the current account.
276    ///
277    /// Scrapes the account purchase history page at `https://store.steampowered.com/account/history/`.
278    ///
279    /// # Returns
280    ///
281    /// Returns a `Vec<PurchaseHistoryItem>` containing all visible purchase
282    /// history entries.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the request fails or the page cannot be parsed.
287    ///
288    /// # Example
289    ///
290    /// ```rust,no_run
291    /// # use steam_user::client::SteamUser;
292    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
293    /// let history = user.get_purchase_history().await?;
294    /// for item in history {
295    ///     println!("{}: {} - {}", item.date, item.transaction_type, item.total);
296    /// }
297    /// # Ok(())
298    /// # }
299    /// ```
300    #[steam_endpoint(GET, host = Store, path = "/account/history/", kind = Read)]
301    pub async fn get_purchase_history(&self) -> Result<Vec<PurchaseHistoryItem>, SteamUserError> {
302        let response = self.get_path("/account/history/").send().await?.text().await?;
303
304        Self::parse_purchase_history_html(&response)
305    }
306
307    /// Pure parsing function for the purchase history page HTML.
308    ///
309    /// Extracted for easier testing and offline parsing.
310    pub fn parse_purchase_history_html(html: &str) -> Result<Vec<PurchaseHistoryItem>, SteamUserError> {
311        let document = Html::parse_document(html);
312
313        // Check for login redirect
314        if let Some(title) = document.select(sel_title()).next() {
315            if title.text().collect::<String>() == "Sign In" {
316                return Err(SteamUserError::Other("Not logged in".into()));
317            }
318        }
319
320        let mut history = Vec::new();
321
322        for row in document.select(sel_wallet_row()) {
323            let date_str = row.select(sel_wht_date()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
324
325            // Try different date formats Steam uses. Steam reports day
326            // granularity; promote to midnight UTC so the stored value is
327            // timezone-unambiguous. Falls back to the chrono default
328            // (1970-01-01) when no format matches.
329            let date_naive = chrono::NaiveDate::parse_from_str(&date_str, "%d %b, %Y").or_else(|_| chrono::NaiveDate::parse_from_str(&date_str, "%e %b, %Y")).or_else(|_| chrono::NaiveDate::parse_from_str(&date_str, "%b %d, %Y")).or_else(|_| chrono::NaiveDate::parse_from_str(&date_str, "%b %e, %Y")).unwrap_or_default();
330            let date = date_naive.and_hms_opt(0, 0, 0).map(|naive| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc)).unwrap_or_default();
331
332            let transaction_type = row.select(sel_wht_type()).next().map(|el| el.text().find(|t| !t.trim().is_empty()).unwrap_or_default().trim().to_string()).unwrap_or_default();
333
334            // Items can have multiple items separated
335            let items: Vec<String> = row.select(sel_wht_items()).next().map(|el| el.text().collect::<String>().lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()).unwrap_or_default();
336
337            let total = row.select(sel_wht_total()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
338
339            let payment_method = row.select(sel_wth_payment()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty());
340
341            let wallet_balance = row.select(sel_wht_wallet()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty());
342
343            let base_price = row.select(sel_wht_base_price()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
344            let tax = row.select(sel_wht_tax()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
345            let shipping = row.select(sel_wht_shipping()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
346            let wallet_change = row.select(sel_wht_wallet_change()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty() && s != "Change");
347
348            // Try to extract transaction ID from data attributes or links
349            let mut transaction_id = row.value().attr("data-transid").or_else(|| row.value().attr("data-transactionid")).map(|s| s.to_string());
350
351            // Sometimes transid is in the onclick handler URL
352            if transaction_id.is_none() {
353                if let Some(onclick) = row.value().attr("onclick") {
354                    if let Some(caps) = re_transid().captures(onclick) {
355                        transaction_id = Some(caps[1].to_string());
356                    }
357                }
358            }
359
360            let transaction_id = transaction_id.map(TransactionId);
361
362            // Only add if we have at least date and type
363            if !transaction_type.is_empty() {
364                history.push(PurchaseHistoryItem {
365                    date,
366                    transaction_type,
367                    items,
368                    total,
369                    base_price,
370                    tax,
371                    shipping,
372                    wallet_change,
373                    payment_method,
374                    wallet_balance,
375                    transaction_id,
376                });
377            }
378        }
379
380        Ok(history)
381    }
382
383    /// Redeems a Steam wallet code to add funds to the account.
384    ///
385    /// Posts to `https://store.steampowered.com/account/ajaxredeemwalletcode/`.
386    ///
387    /// # Arguments
388    ///
389    /// * `wallet_code` - The Steam wallet code to redeem (e.g.,
390    ///   "XXXXX-XXXXX-XXXXX").
391    ///
392    /// # Returns
393    ///
394    /// Returns a [`RedeemWalletCodeResponse`] containing the result of the
395    /// redemption.
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if the request fails. Common error codes in the
400    /// response:
401    /// - `success: 1` - Code redeemed successfully
402    /// - `success: 2` with `detail: 14` - Invalid code
403    /// - `success: 2` with `detail: 15` - Already redeemed
404    /// - `success: 2` with `detail: 53` - Rate limited
405    ///
406    /// # Example
407    ///
408    /// ```rust,no_run
409    /// # use steam_user::client::SteamUser;
410    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
411    /// let result = user.redeem_wallet_code("XXXXX-XXXXX-XXXXX").await?;
412    /// if result.success == 1 {
413    ///     println!(
414    ///         "Redeemed! New balance: {:?}",
415    ///         result.formatted_new_wallet_balance
416    ///     );
417    /// } else {
418    ///     println!("Failed with detail: {:?}", result.detail);
419    /// }
420    /// # Ok(())
421    /// # }
422    /// ```
423    #[steam_endpoint(POST, host = Store, path = "/account/ajaxredeemwalletcode/", kind = Write)]
424    pub async fn redeem_wallet_code(&self, wallet_code: &str) -> Result<RedeemWalletCodeResponse, SteamUserError> {
425        let response: RedeemWalletCodeResponse = self.post_path("/account/ajaxredeemwalletcode/").form(&[("wallet_code", wallet_code)]).send().await?.json().await?;
426
427        Ok(response)
428    }
429
430    /// Fetches and parses the Steam authorized-devices page.
431    ///
432    /// Scrapes `https://store.steampowered.com/account/authorizeddevices` and
433    /// extracts every JSON data blob embedded by Steam into the page:
434    ///
435    /// | Field | Source attribute |
436    /// |---|---|
437    /// | `active_devices` | `data-active_devices` |
438    /// | `revoked_devices` | `data-revoked_devices` |
439    /// | `two_factor_status` | `data-two_factor_status` |
440    /// | `user_info` | `data-userinfo` |
441    /// | `hw_info` | `data-hwinfo` |
442    /// | `page_config` | `data-config` |
443    /// | `store_user_config` | `data-store_user_config` (includes WebAPI JWT) |
444    /// | `notifications` | `data-steam_notifications` |
445    /// | `broadcast_user` | `data-broadcastuser` |
446    /// | `account_name` | `data-accountName` |
447    /// | `email` | `data-email` |
448    /// | `phone_hint` | `data-phone_hint` |
449    /// | `latest_android_app_version` | `data-latest_android_app_version` |
450    /// | `requesting_token_id` | `data-requesting_token_id` |
451    ///
452    /// # Errors
453    ///
454    /// Returns [`SteamUserError::Other("Not logged in")`] if the session is
455    /// unauthenticated (Steam redirects to the Sign In page).
456    ///
457    /// # Example
458    ///
459    /// ```rust,no_run
460    /// # use steam_user::client::SteamUser;
461    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
462    /// let page = user.get_account_details().await?;
463    /// println!("Account: {:?}", page.account_name);
464    /// println!("Country: {:?}", page.country);
465    /// println!("Security: {:?}", page.account_security());
466    /// println!("Active sessions: {}", page.active_devices.len());
467    /// # Ok(())
468    /// # }
469    /// ```
470    #[steam_endpoint(GET, host = Store, path = "/account/authorizeddevices", kind = Read)]
471    pub async fn get_account_details(&self) -> Result<AccountDetails, SteamUserError> {
472        let response = self.get_path("/account/authorizeddevices").send().await?.text().await?;
473
474        let document = Html::parse_document(&response);
475        if let Some(title) = document.select(sel_title()).next() {
476            if title.text().collect::<String>() == "Sign In" {
477                return Err(SteamUserError::Other("Not logged in".into()));
478            }
479        }
480
481        Ok(parse_account_details_html(&response))
482    }
483}
484
485/// Parse an account-details page HTML string into [`AccountDetails`].
486///
487/// Pure parsing — no network request. Pass the raw HTML from
488/// `https://store.steampowered.com/account/authorizeddevices`.
489/// Useful for testing against saved HTML or caching the raw response.
490pub fn parse_account_details_html(html: &str) -> AccountDetails {
491    let document = Html::parse_document(html);
492
493    fn parse_json<T: for<'de> serde::Deserialize<'de>>(doc: &Html, attr: &str) -> Option<T> {
494        let sel = Selector::parse(&format!("[{}]", attr)).ok()?;
495        let val = doc.select(&sel).next()?.value().attr(attr)?;
496        serde_json::from_str(val).ok()
497    }
498
499    fn parse_str(doc: &Html, attr: &str) -> Option<String> {
500        let sel = Selector::parse(&format!("[{}]", attr)).ok()?;
501        let val = doc.select(&sel).next()?.value().attr(attr)?;
502        serde_json::from_str(val).ok()
503    }
504
505    let mut page = AccountDetails {
506        active_devices: parse_json::<Vec<_>>(&document, "data-active_devices").unwrap_or_default(),
507        revoked_devices: parse_json::<Vec<_>>(&document, "data-revoked_devices").unwrap_or_default(),
508        two_factor_status: parse_json(&document, "data-two_factor_status"),
509        user_info: parse_json(&document, "data-userinfo"),
510        hw_info: parse_json(&document, "data-hwinfo"),
511        page_config: parse_json(&document, "data-config"),
512        store_user_config: parse_json(&document, "data-store_user_config"),
513        notifications: parse_json(&document, "data-steam_notifications"),
514        broadcast_user: parse_json(&document, "data-broadcastuser"),
515        account_name: parse_str(&document, "data-accountname"),
516        email: parse_str(&document, "data-email"),
517        phone_hint: parse_str(&document, "data-phone_hint"),
518        latest_android_app_version: parse_str(&document, "data-latest_android_app_version"),
519        requesting_token_id: parse_str(&document, "data-requesting_token_id"),
520        wallet_balance: Some(parse_wallet_balance(&document)).filter(|w| w.main_balance.is_some()),
521        ..Default::default()
522    };
523
524    page.avatar_hash = document.select(sel_player_avatar_img()).next().and_then(|el| el.value().attr("src")).and_then(get_avatar_hash_from_url);
525
526    page.country = page.user_info.as_ref().and_then(|u| u.country_code.clone());
527
528    page
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn test_parse_purchase_history() {
537        let html = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/steam_response/get_purchase_history.html")).expect("Failed to read HTML file");
538
539        // The file `get_purchase_history.html` is saved as a view-source page from
540        // Chrome, so it's nested in `.line-content`
541        let mut html_to_parse = String::new();
542        for line in html.lines() {
543            if line.contains("<td class=\"line-content\">") {
544                let text = line.replace("<td class=\"line-content\">", "").replace("</td>", "");
545                html_to_parse.push_str(&text);
546                html_to_parse.push('\n');
547            }
548        }
549
550        if html_to_parse.trim().is_empty() {
551            html_to_parse = html;
552        }
553
554        // We must decode html entities to turn &lt; back to <
555        let html_to_parse = html_to_parse.replace("<span class=\"html-tag\">", "").replace("<span class=\"html-attribute-name\">", "").replace("<span class=\"html-attribute-value\">", "").replace("<a class=\"html-attribute-value html-external-link\"", "<a").replace("</span>", "").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&amp;", "&");
556
557        let result = SteamUser::parse_purchase_history_html(&html_to_parse);
558        assert!(result.is_ok(), "Should parse HTML successfully: {:?}", result.err());
559        let history = result.unwrap();
560
561        assert!(!history.is_empty(), "History should not be empty, html string length: {}", html_to_parse.len());
562
563        // Verify the first item (noting the test data structure might vary, so adapt to
564        // it)
565        let first = &history[0];
566        // The date parsing relies on proper dates in HTML, we skip the exact date check
567        // if it fails and just check it parses
568        assert!(!first.transaction_type.is_empty());
569        assert!(!first.total.is_empty());
570
571        tracing::info!("Successfully parsed {} history items", history.len());
572    }
573}