From f708402e8a4322c0725b4e805fcde106284add98 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 27 Jul 2026 10:03:13 -0400 Subject: [PATCH] grab billing data for reports (#1810) --- .../billing/get_billing_payments_history.rs | 421 ++++++++++++++++++ .../src/billing/get_credits_history.rs | 415 +++++++++++++++++ .../seedance2pro_client/src/billing/mod.rs | 2 + .../seedance2pro_client/src/lib.rs | 1 + .../src/test_utils/setup_test_logging.rs | 6 +- .../src/utils/number_coercion.rs | 41 ++ .../src/commands/seedance2pro/dispatch.rs | 23 + .../seedance2pro/subcommands/account_info.rs | 46 ++ .../seedance2pro/subcommands/audit_credits.rs | 167 +++++++ .../seedance2pro/subcommands/audit_orders.rs | 243 ++++++++++ .../subcommands/audit_payments.rs | 131 ++++++ .../commands/seedance2pro/subcommands/mod.rs | 4 + 12 files changed, 1498 insertions(+), 2 deletions(-) create mode 100644 crates/api_clients/seedance2pro_client/src/billing/get_billing_payments_history.rs create mode 100644 crates/api_clients/seedance2pro_client/src/billing/get_credits_history.rs create mode 100644 crates/api_clients/seedance2pro_client/src/billing/mod.rs create mode 100644 crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/account_info.rs create mode 100644 crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_credits.rs create mode 100644 crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_orders.rs create mode 100644 crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_payments.rs diff --git a/crates/api_clients/seedance2pro_client/src/billing/get_billing_payments_history.rs b/crates/api_clients/seedance2pro_client/src/billing/get_billing_payments_history.rs new file mode 100644 index 0000000000..4a642b6719 --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/billing/get_billing_payments_history.rs @@ -0,0 +1,421 @@ +use chrono::{DateTime, Utc}; +use log::info; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +use crate::creds::seedance2pro_session::Seedance2ProSession; +use crate::error::seedance2pro_client_error::Seedance2ProClientError; +use crate::error::seedance2pro_error::Seedance2ProError; +use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::number_coercion::de_u64_int_or_float; + +/// Page size the kinovi.ai billing dashboard uses. +pub const DEFAULT_BILLING_PAGE_LIMIT: u32 = 20; + +// --- Args & response --- + +pub struct GetBillingPaymentsHistoryArgs<'a> { + pub session: &'a Seedance2ProSession, + + /// Page size. The web dashboard uses [`DEFAULT_BILLING_PAGE_LIMIT`]. + pub limit: u32, + + /// Zero-based row offset. Pagination is offset-based: advance by the + /// number of payments actually returned until a page comes back empty. + pub offset: u64, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct BillingPaymentsHistoryPage { + /// Payments on this page, newest first. + pub payments: Vec, + + /// Cursor of the row after this page (`None` on the last page). The + /// dashboard paginates by limit/offset; this is informational. + pub maybe_next_cursor: Option, + + /// Total payment rows on the account, per the server. + pub total: u64, +} + +// --- Public types --- + +/// One payment on the Kinovi billing dashboard (a credit-package purchase). +#[derive(Debug, Clone)] +pub struct BillingPayment { + pub id: String, + + /// Payment amount in US dollars (the API returns dollars: `2159`, `99.99`). + pub amount_usd: f64, + + pub status: BillingPaymentStatus, + + pub payment_type: BillingPaymentType, + + /// Kinovi credits granted by this payment (e.g. 525,000). + pub credits_earned: u64, + + /// e.g. "Credits Package" + pub product_name: String, + + pub transaction_id: String, + pub payment_id: String, + pub product_id: String, + + /// ISO 8601 creation timestamp (e.g. `"2026-07-13T20:40:49.405Z"`). + pub created_at: String, + + /// Parsed `created_at`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, + + pub updated_at: String, +} + +/// Payment status. Only `PAID` has been observed; unrecognised values are +/// preserved in `Unknown` so future statuses (refunds, chargebacks, pending) +/// don't break deserialization. +#[derive(Debug, Clone, PartialEq)] +pub enum BillingPaymentStatus { + Paid, + Unknown(String), +} + +impl BillingPaymentStatus { + fn from_str(s: &str) -> Self { + match s { + "PAID" => Self::Paid, + other => Self::Unknown(other.to_string()), + } + } +} + +/// Payment type. Only `ONE_TIME` has been observed. +#[derive(Debug, Clone, PartialEq)] +pub enum BillingPaymentType { + OneTime, + Unknown(String), +} + +impl BillingPaymentType { + fn from_str(s: &str) -> Self { + match s { + "ONE_TIME" => Self::OneTime, + other => Self::Unknown(other.to_string()), + } + } +} + +// --- Implementation --- + +pub async fn get_billing_payments_history( + args: GetBillingPaymentsHistoryArgs<'_>, +) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/billing.getPayments", base_url); + + info!("Fetching billing payments (limit: {}, offset: {})...", args.limit, args.offset); + + let input_json = build_input_json(args.limit, args.offset); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| Seedance2ProClientError::WreqClientError(err))?; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/dashboard/billing", base_url); + + let request = client.get(&url) + .query(&[("batch", "1"), ("input", input_json.as_str())]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .build() + .map_err(|err| Seedance2ProClientError::WreqClientError(err))?; + + let response = client.execute(request) + .await + .map_err(|err| Seedance2ProGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| Seedance2ProGenericApiError::WreqError(err))?; + + info!("Billing payments response status: {}", status); + + if !status.is_success() { + return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + parse_response_body(&response_body) +} + +fn parse_response_body(response_body: &str) -> Result { + let batch_response: Vec = serde_json::from_str(response_body) + .map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let json = batch_response + .into_iter() + .next() + .ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.to_string(), + })? + .result + .data + .json; + + let payments = json.payments + .into_iter() + .map(|p| { + let created_at_utc = DateTime::parse_from_rfc3339(&p.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .ok(); + BillingPayment { + id: p.id, + amount_usd: p.amount, + status: BillingPaymentStatus::from_str(&p.payment_status), + payment_type: BillingPaymentType::from_str(&p.payment_type), + credits_earned: p.credits_earned, + product_name: p.product_name, + transaction_id: p.transaction_id, + payment_id: p.payment_id, + product_id: p.product_id, + created_at: p.created_at, + created_at_utc, + updated_at: p.updated_at, + } + }) + .collect(); + + Ok(BillingPaymentsHistoryPage { + payments, + maybe_next_cursor: json.next_cursor, + total: json.total, + }) +} + +/// Builds the tRPC `input` JSON for the billing.getPayments endpoint. +fn build_input_json(limit: u32, offset: u64) -> String { + format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#) +} + +// --- Raw response types --- + +#[derive(Deserialize, Debug)] +struct BatchResponseItem { + result: RawResult, +} + +#[derive(Deserialize, Debug)] +struct RawResult { + data: RawData, +} + +#[derive(Deserialize, Debug)] +struct RawData { + json: RawJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawJson { + payments: Vec, + #[serde(default)] + next_cursor: Option, + #[serde(deserialize_with = "de_u64_int_or_float")] + total: u64, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawPayment { + id: String, + amount: f64, + payment_status: String, + #[serde(default)] + transaction_id: String, + #[serde(default)] + payment_id: String, + #[serde(default)] + product_id: String, + #[serde(rename = "type")] + payment_type: String, + #[serde(deserialize_with = "de_u64_int_or_float")] + credits_earned: u64, + #[serde(default)] + product_name: String, + created_at: String, + #[serde(default)] + updated_at: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::seedance2pro_session::Seedance2ProSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod offline_parsing { + use super::*; + + /// Trimmed verbatim from a captured billing.getPayments response + /// (external/requests/sites/kinovi.ai/2026-07-13-billing/). + const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"payments":[ + {"id":"31722","amount":2159,"paymentStatus":"PAID","transactionId":"","paymentId":"","createdAt":"2026-07-13T20:40:49.405Z","updatedAt":"2026-07-13T20:40:49.405Z","productId":"","type":"ONE_TIME","creditsEarned":525000,"productName":"Credits Package"}, + {"id":"11491","amount":99.99,"paymentStatus":"PAID","transactionId":"202603141400154949180","paymentId":"202603141400154949180","createdAt":"2026-03-14T14:00:15.782Z","updatedAt":"2026-03-14T14:01:02.630Z","productId":"","type":"ONE_TIME","creditsEarned":22000,"productName":"Credits Package"} + ],"nextCursor":"29564","total":997}}}}]"#; + + #[test] + fn parses_captured_page() { + let page = parse_response_body(CAPTURED_PAGE).expect("parse"); + assert_eq!(page.payments.len(), 2); + assert_eq!(page.maybe_next_cursor.as_deref(), Some("29564")); + assert_eq!(page.total, 997); + + let first = &page.payments[0]; + assert_eq!(first.id, "31722"); + assert_eq!(first.amount_usd, 2159.0); + assert_eq!(first.status, BillingPaymentStatus::Paid); + assert_eq!(first.payment_type, BillingPaymentType::OneTime); + assert_eq!(first.credits_earned, 525_000); + assert!(first.created_at_utc.is_some()); + + let second = &page.payments[1]; + assert_eq!(second.amount_usd, 99.99); + assert_eq!(second.transaction_id, "202603141400154949180"); + } + + #[test] + fn last_page_has_no_next_cursor() { + let body = r#"[{"result":{"data":{"json":{"payments":[],"nextCursor":null,"total":997}}}}]"#; + let page = parse_response_body(body).expect("parse"); + assert!(page.payments.is_empty()); + assert_eq!(page.maybe_next_cursor, None); + } + + #[test] + fn unknown_status_and_type_are_preserved() { + let body = r#"[{"result":{"data":{"json":{"payments":[ + {"id":"1","amount":5,"paymentStatus":"REFUNDED","type":"SUBSCRIPTION","creditsEarned":0,"createdAt":"2026-07-13T20:40:49.405Z"} + ],"nextCursor":null,"total":1}}}}]"#; + let page = parse_response_body(body).expect("parse"); + assert_eq!(page.payments[0].status, BillingPaymentStatus::Unknown("REFUNDED".to_string())); + assert_eq!(page.payments[0].payment_type, BillingPaymentType::Unknown("SUBSCRIPTION".to_string())); + } + } + + mod live { + use super::*; + + #[tokio::test] + #[ignore] // manual test — requires real cookies + async fn live_fetch_first_payments_page() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs { + session: &session, + limit: DEFAULT_BILLING_PAGE_LIMIT, + offset: 0, + host_override: None, + }).await?; + + println!("payments: {}, total: {}, next_cursor: {:?}", + page.payments.len(), page.total, page.maybe_next_cursor); + for payment in &page.payments { + println!(" {} | {} | ${:.2} | {} credits | {:?} | {:?}", + payment.id, payment.created_at, payment.amount_usd, + payment.credits_earned, payment.status, payment.payment_type); + } + assert!(!page.payments.is_empty()); + Ok(()) + } + + /// Pages through the ENTIRE payments history with throttling and prints + /// lifetime totals. ~1,000 rows / 50 pages as of 2026-07. + /// cargo test -p seedance2pro_client live_paginate_entire_payments_history -- --ignored --nocapture + #[tokio::test] + #[ignore] // manual test — requires real cookies; fetches every page + async fn live_paginate_entire_payments_history() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + const PAGE_DELAY_MS: u64 = 250; + const MAX_PAGES: usize = 2_000; // runaway guard + + let mut offset: u64 = 0; + let mut pages = 0usize; + let mut total_rows = 0u64; + let mut total_amount_usd = 0f64; + let mut total_credits = 0u64; + let mut last_reported_total = 0u64; + + for _ in 0..MAX_PAGES { + let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs { + session: &session, + limit: DEFAULT_BILLING_PAGE_LIMIT, + offset, + host_override: None, + }).await?; + + pages += 1; + last_reported_total = page.total; + if page.payments.is_empty() { + break; + } + + for payment in &page.payments { + total_rows += 1; + total_amount_usd += payment.amount_usd; + total_credits += payment.credits_earned; + } + + println!("page {} (offset {}): {} payments, running total ${:.2}", + pages, offset, page.payments.len(), total_amount_usd); + + // Advance by what was actually returned so a server-side limit cap + // can never skip rows. + offset += page.payments.len() as u64; + tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await; + } + + println!("── payments history totals ──"); + println!("pages: {}", pages); + println!("payments: {} (server reports total {})", total_rows, last_reported_total); + println!("lifetime spend: ${:.2}", total_amount_usd); + println!("lifetime credits: {}", total_credits); + assert!(total_rows > 0); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(Seedance2ProSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/seedance2pro_client/src/billing/get_credits_history.rs b/crates/api_clients/seedance2pro_client/src/billing/get_credits_history.rs new file mode 100644 index 0000000000..1f3d22c320 --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/billing/get_credits_history.rs @@ -0,0 +1,415 @@ +use chrono::{DateTime, Utc}; +use log::info; +use serde::Deserialize; +use wreq::Client; +use wreq_util::Emulation; + +use crate::creds::seedance2pro_session::Seedance2ProSession; +use crate::error::seedance2pro_client_error::Seedance2ProClientError; +use crate::error::seedance2pro_error::Seedance2ProError; +use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError; +use crate::requests::kinovi_host::{KinoviHost, resolve_host}; +use crate::utils::common_headers::FIREFOX_USER_AGENT; +use crate::utils::number_coercion::{de_i64_int_or_float, de_u64_int_or_float}; + +/// Page size the kinovi.ai billing dashboard uses. +pub const DEFAULT_CREDITS_PAGE_LIMIT: u32 = 20; + +// --- Args & response --- + +pub struct GetCreditsHistoryArgs<'a> { + pub session: &'a Seedance2ProSession, + + /// Page size. The web dashboard uses [`DEFAULT_CREDITS_PAGE_LIMIT`]. + pub limit: u32, + + /// Zero-based row offset. Pagination is offset-based: advance by the + /// number of entries actually returned until a page comes back empty. + /// NB: the ledger grows while you page (offsets shift under live traffic), + /// so dedupe by entry `id` when sweeping the full history. + pub offset: u64, + + /// Override the default host (kinovi.ai). + pub host_override: Option, +} + +pub struct CreditsHistoryPage { + /// Ledger entries on this page, newest first. + pub entries: Vec, + + /// Total ledger rows on the account, per the server. + pub total: u64, +} + +// --- Public types --- + +/// One row of the Kinovi credits ledger: a signed credit delta attributed to +/// a generation order (consumption) or a grant (purchase, registration bonus). +#[derive(Debug, Clone)] +pub struct CreditHistoryEntry { + pub id: u64, + + pub entry_type: CreditHistoryEntryType, + + /// Signed credit delta: negative for consumption, positive for grants. + pub credit_delta: i64, + + /// The generation order this entry bills, when applicable (`ord_...`). + /// `None` for grants (purchases, registration bonuses). + pub maybe_order_id: Option, + + /// e.g. "Seedance 2.0 Video", "Credit Package - xl (25000 credits)" + pub product_name: String, + + /// ISO 8601 creation timestamp (e.g. `"2026-07-14T00:31:12.820Z"`). + pub created_at: String, + + /// Parsed `created_at`. `None` if the raw string could not be parsed. + pub created_at_utc: Option>, +} + +/// Ledger entry type. Observed values are mapped; anything new (refunds, +/// failure credits, expirations, ...) lands in `Unknown` so deserialization +/// never breaks. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CreditHistoryEntryType { + /// Credits consumed by a successful generation (negative delta). + ConsumptionSuccess, + /// Credits granted by a package purchase (positive delta). + PurchasedCredits, + /// Sign-up bonus credits (positive delta). + NewUserRegistration, + Unknown(String), +} + +impl CreditHistoryEntryType { + pub fn as_str(&self) -> &str { + match self { + Self::ConsumptionSuccess => "CONSUMPTION_SUCCESS", + Self::PurchasedCredits => "PURCHASED_CREDITS", + Self::NewUserRegistration => "NEW_USER_REGISTRATION", + Self::Unknown(other) => other.as_str(), + } + } + + fn from_str(s: &str) -> Self { + match s { + "CONSUMPTION_SUCCESS" => Self::ConsumptionSuccess, + "PURCHASED_CREDITS" => Self::PurchasedCredits, + "NEW_USER_REGISTRATION" => Self::NewUserRegistration, + other => Self::Unknown(other.to_string()), + } + } +} + +// --- Implementation --- + +pub async fn get_credits_history( + args: GetCreditsHistoryArgs<'_>, +) -> Result { + let host = resolve_host(args.host_override.as_ref()); + let base_url = host.api_base_url(); + let url = format!("{}/api/trpc/credits.getCreditHistory", base_url); + + info!("Fetching credits history (limit: {}, offset: {})...", args.limit, args.offset); + + let input_json = build_input_json(args.limit, args.offset); + + let client = Client::builder() + .emulation(Emulation::Firefox143) + .build() + .map_err(|err| Seedance2ProClientError::WreqClientError(err))?; + + let cookie = args.session.cookies.as_str(); + let referer = format!("{}/app/dashboard/billing", base_url); + + let request = client.get(&url) + .query(&[("batch", "1"), ("input", input_json.as_str())]) + .header("User-Agent", FIREFOX_USER_AGENT) + .header("Accept", "*/*") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Referer", &referer) + .header("content-type", "application/json") + .header("x-trpc-source", "client") + .header("Connection", "keep-alive") + .header("Cookie", cookie) + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Priority", "u=4") + .header("TE", "trailers") + .build() + .map_err(|err| Seedance2ProClientError::WreqClientError(err))?; + + let response = client.execute(request) + .await + .map_err(|err| Seedance2ProGenericApiError::WreqError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| Seedance2ProGenericApiError::WreqError(err))?; + + info!("Credits history response status: {}", status); + + if !status.is_success() { + return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + parse_response_body(&response_body) +} + +fn parse_response_body(response_body: &str) -> Result { + let batch_response: Vec = serde_json::from_str(response_body) + .map_err(|err| Seedance2ProGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.to_string()))?; + + let json = batch_response + .into_iter() + .next() + .ok_or_else(|| Seedance2ProGenericApiError::UnexpectedResponseShape { + explanation: "Empty batch response array".to_string(), + raw_body: response_body.to_string(), + })? + .result + .data + .json; + + let entries = json.credits + .into_iter() + .map(|entry| { + let created_at_utc = DateTime::parse_from_rfc3339(&entry.created_at) + .map(|dt| dt.with_timezone(&Utc)) + .ok(); + CreditHistoryEntry { + id: entry.id, + entry_type: CreditHistoryEntryType::from_str(&entry.entry_type), + credit_delta: entry.credit, + maybe_order_id: entry.order_id, + product_name: entry.product_name, + created_at: entry.created_at, + created_at_utc, + } + }) + .collect(); + + Ok(CreditsHistoryPage { + entries, + total: json.total, + }) +} + +/// Builds the tRPC `input` JSON for the credits.getCreditHistory endpoint. +fn build_input_json(limit: u32, offset: u64) -> String { + format!(r#"{{"0":{{"json":{{"limit":{limit},"offset":{offset}}}}}}}"#) +} + +// --- Raw response types --- + +#[derive(Deserialize, Debug)] +struct BatchResponseItem { + result: RawResult, +} + +#[derive(Deserialize, Debug)] +struct RawResult { + data: RawData, +} + +#[derive(Deserialize, Debug)] +struct RawData { + json: RawJson, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawJson { + credits: Vec, + #[serde(deserialize_with = "de_u64_int_or_float")] + total: u64, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawCreditEntry { + #[serde(deserialize_with = "de_u64_int_or_float")] + id: u64, + #[serde(rename = "type")] + entry_type: String, + #[serde(deserialize_with = "de_i64_int_or_float")] + credit: i64, + #[serde(default)] + order_id: Option, + created_at: String, + #[serde(default)] + product_name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::creds::seedance2pro_session::Seedance2ProSession; + use crate::test_utils::get_test_cookies::get_test_cookies; + use crate::test_utils::setup_test_logging::setup_test_logging; + use errors::AnyhowResult; + use log::LevelFilter; + + mod offline_parsing { + use super::*; + + /// Trimmed verbatim from a captured credits.getCreditHistory response + /// (external/requests/sites/kinovi.ai/2026-07-13-billing/). + const CAPTURED_PAGE: &str = r#"[{"result":{"data":{"json":{"credits":[ + {"id":2750659,"type":"CONSUMPTION_SUCCESS","credit":-454,"orderId":"ord_mff2cjk2feczmzqweizcdal2","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"}, + {"id":591799,"type":"PURCHASED_CREDITS","credit":25000,"orderId":null,"createdAt":"2026-02-19T08:16:58.524Z","productName":"Credit Package - xl (25000 credits)"}, + {"id":563856,"type":"NEW_USER_REGISTRATION","credit":200,"orderId":null,"createdAt":"2026-02-18T01:02:52.747Z","productName":"unknown"} + ],"total":304617}}}}]"#; + + #[test] + fn parses_captured_page() { + let page = parse_response_body(CAPTURED_PAGE).expect("parse"); + assert_eq!(page.entries.len(), 3); + assert_eq!(page.total, 304_617); + + let consumption = &page.entries[0]; + assert_eq!(consumption.id, 2_750_659); + assert_eq!(consumption.entry_type, CreditHistoryEntryType::ConsumptionSuccess); + assert_eq!(consumption.credit_delta, -454); + assert_eq!(consumption.maybe_order_id.as_deref(), Some("ord_mff2cjk2feczmzqweizcdal2")); + assert!(consumption.created_at_utc.is_some()); + + let purchase = &page.entries[1]; + assert_eq!(purchase.entry_type, CreditHistoryEntryType::PurchasedCredits); + assert_eq!(purchase.credit_delta, 25_000); + assert_eq!(purchase.maybe_order_id, None); + + let registration = &page.entries[2]; + assert_eq!(registration.entry_type, CreditHistoryEntryType::NewUserRegistration); + assert_eq!(registration.credit_delta, 200); + } + + #[test] + fn unknown_entry_type_is_preserved() { + let body = r#"[{"result":{"data":{"json":{"credits":[ + {"id":1,"type":"CONSUMPTION_REFUND","credit":454,"orderId":"ord_x","createdAt":"2026-07-14T00:31:12.820Z","productName":"Seedance 2.0 Video"} + ],"total":1}}}}]"#; + let page = parse_response_body(body).expect("parse"); + assert_eq!( + page.entries[0].entry_type, + CreditHistoryEntryType::Unknown("CONSUMPTION_REFUND".to_string()), + ); + assert_eq!(page.entries[0].credit_delta, 454); + } + + #[test] + fn entry_type_round_trips_as_str() { + for raw in ["CONSUMPTION_SUCCESS", "PURCHASED_CREDITS", "NEW_USER_REGISTRATION", "SOMETHING_NEW"] { + assert_eq!(CreditHistoryEntryType::from_str(raw).as_str(), raw); + } + } + } + + mod live { + use super::*; + use std::collections::HashMap; + use std::collections::HashSet; + + #[tokio::test] + #[ignore] // manual test — requires real cookies + async fn live_fetch_first_credits_page() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + let page = get_credits_history(GetCreditsHistoryArgs { + session: &session, + limit: DEFAULT_CREDITS_PAGE_LIMIT, + offset: 0, + host_override: None, + }).await?; + + println!("entries: {}, total: {}", page.entries.len(), page.total); + for entry in &page.entries { + println!(" {} | {} | {:?} | {} | {:?}", + entry.id, entry.created_at, entry.entry_type, entry.credit_delta, + entry.maybe_order_id); + } + assert!(!page.entries.is_empty()); + Ok(()) + } + + /// Pages through the ENTIRE credits ledger with throttling and prints + /// per-type tallies (this is where any refund-like entry types will + /// surface). ~300k rows as of 2026-07 — expect a long run; the ledger + /// also grows while paging, so entries are deduped by id. + /// cargo test -p seedance2pro_client live_paginate_entire_credits_history -- --ignored --nocapture + #[tokio::test] + #[ignore] // manual test — requires real cookies; fetches every page + async fn live_paginate_entire_credits_history() -> AnyhowResult<()> { + setup_test_logging(LevelFilter::Info); + let session = test_session()?; + + const PAGE_LIMIT: u32 = 100; + const PAGE_DELAY_MS: u64 = 250; + const MAX_PAGES: usize = 50_000; // runaway guard + + let mut offset: u64 = 0; + let mut pages = 0usize; + let mut seen_ids: HashSet = HashSet::new(); + let mut type_counts: HashMap = HashMap::new(); + let mut last_reported_total = 0u64; + + for _ in 0..MAX_PAGES { + let page = get_credits_history(GetCreditsHistoryArgs { + session: &session, + limit: PAGE_LIMIT, + offset, + host_override: None, + }).await?; + + pages += 1; + last_reported_total = page.total; + if page.entries.is_empty() { + break; + } + + for entry in &page.entries { + if !seen_ids.insert(entry.id) { + continue; // offset shifted under live traffic; already counted + } + let tally = type_counts.entry(entry.entry_type.clone()).or_insert((0, 0)); + tally.0 += 1; + tally.1 += entry.credit_delta; + } + + if pages % 25 == 0 { + println!("page {} (offset {}): {} unique entries so far (server total {})", + pages, offset, seen_ids.len(), page.total); + } + + // Advance by what was actually returned so a server-side limit cap + // can never skip rows. + offset += page.entries.len() as u64; + tokio::time::sleep(std::time::Duration::from_millis(PAGE_DELAY_MS)).await; + } + + println!("── credits ledger totals ──"); + println!("pages: {}", pages); + println!("unique entries: {} (server reports total {})", seen_ids.len(), last_reported_total); + let mut types: Vec<_> = type_counts.iter().collect(); + types.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count)); + for (entry_type, (count, delta_sum)) in types { + println!(" {:<28} n={:<8} credit_sum={}", entry_type.as_str(), count, delta_sum); + } + assert!(!seen_ids.is_empty()); + Ok(()) + } + + fn test_session() -> AnyhowResult { + let cookies = get_test_cookies()?; + Ok(Seedance2ProSession::from_cookies_string(cookies)) + } + } +} diff --git a/crates/api_clients/seedance2pro_client/src/billing/mod.rs b/crates/api_clients/seedance2pro_client/src/billing/mod.rs new file mode 100644 index 0000000000..fb75bf5bce --- /dev/null +++ b/crates/api_clients/seedance2pro_client/src/billing/mod.rs @@ -0,0 +1,2 @@ +pub mod get_billing_payments_history; +pub mod get_credits_history; diff --git a/crates/api_clients/seedance2pro_client/src/lib.rs b/crates/api_clients/seedance2pro_client/src/lib.rs index 8e47c24bdf..9571caee4a 100644 --- a/crates/api_clients/seedance2pro_client/src/lib.rs +++ b/crates/api_clients/seedance2pro_client/src/lib.rs @@ -2,6 +2,7 @@ pub (crate) mod test_utils; // User API +pub mod billing; pub mod cost; pub mod creds; pub mod error; diff --git a/crates/api_clients/seedance2pro_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/seedance2pro_client/src/test_utils/setup_test_logging.rs index 8c8a1ba71a..cdb6c4e517 100644 --- a/crates/api_clients/seedance2pro_client/src/test_utils/setup_test_logging.rs +++ b/crates/api_clients/seedance2pro_client/src/test_utils/setup_test_logging.rs @@ -19,7 +19,9 @@ pub fn setup_test_logging(level: LevelFilter) { println!("Log level: {:?}", env::var(RUST_LOG)); - Builder::new() + // try_init: several #[ignore] live tests may run in one process, and only + // the first can install the logger. + let _ = Builder::new() .is_test(true) .format(|buf, record| { writeln!(buf, @@ -31,7 +33,7 @@ pub fn setup_test_logging(level: LevelFilter) { }) .filter(None, level) .filter_level(level) - .init(); + .try_init(); trace!("Test trace log"); debug!("Test debug log"); diff --git a/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs b/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs index 250021bc45..cf2ab1324f 100644 --- a/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs +++ b/crates/api_clients/seedance2pro_client/src/utils/number_coercion.rs @@ -28,6 +28,20 @@ fn coerce_to_u32(number: &Number) -> u32 { coerce_to_u64(number).min(u32::MAX as u64) as u32 } +/// Coerce a parsed JSON number to `i64`. Exact integers pass through; floats +/// are rounded; non-finite values become `0`. +fn coerce_to_i64(number: &Number) -> i64 { + if let Some(signed) = number.as_i64() { + return signed; + } + if let Some(float) = number.as_f64() { + if float.is_finite() { + return float.round() as i64; + } + } + 0 +} + /// `#[serde(deserialize_with = "...")]` for a required `u64` field that may /// arrive as an integer or a float. pub fn de_u64_int_or_float<'de, D>(deserializer: D) -> Result @@ -38,6 +52,16 @@ where Ok(coerce_to_u64(&number)) } +/// `#[serde(deserialize_with = "...")]` for a required `i64` field that may +/// arrive as an integer or a float (e.g. signed credit deltas). +pub fn de_i64_int_or_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let number = Number::deserialize(deserializer)?; + Ok(coerce_to_i64(&number)) +} + /// `#[serde(deserialize_with = "...")]` for an optional `u64` field (handles /// `null` / missing as `None`). pub fn de_opt_u64_int_or_float<'de, D>(deserializer: D) -> Result, D::Error> @@ -90,6 +114,12 @@ mod tests { value: Option, } + #[derive(Deserialize)] + struct I64Holder { + #[serde(deserialize_with = "de_i64_int_or_float")] + value: i64, + } + fn parse_u32(json: &str) -> u32 { serde_json::from_str::(json).unwrap().value } @@ -98,6 +128,10 @@ mod tests { serde_json::from_str::(json).unwrap().value } + fn parse_i64(json: &str) -> i64 { + serde_json::from_str::(json).unwrap().value + } + #[test] fn u32_accepts_integer() { assert_eq!(parse_u32(r#"{"value": 568}"#), 568); @@ -126,6 +160,13 @@ mod tests { assert_eq!(parse_u64(r#"{"value": -5.5}"#), 0); } + #[test] + fn i64_accepts_negative_integers_and_floats() { + assert_eq!(parse_i64(r#"{"value": -454}"#), -454); + assert_eq!(parse_i64(r#"{"value": -454.6}"#), -455); + assert_eq!(parse_i64(r#"{"value": 25000}"#), 25000); + } + #[test] fn optional_handles_null_missing_and_values() { assert_eq!(serde_json::from_str::(r#"{"value": null}"#).unwrap().value, None); diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/dispatch.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/dispatch.rs index 007f2b7b9d..011ee7cbc9 100644 --- a/crates/cli/production/support-tool/src/commands/seedance2pro/dispatch.rs +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/dispatch.rs @@ -7,6 +7,10 @@ use super::subcommands; /// All canonical subcommand names for this module. /// Used by the underscore-insensitive arg normalizer. pub const SUBCOMMAND_NAMES: &[&str] = &[ + "account_info", + "audit_credits", + "audit_orders", + "audit_payments", "failed_job_histogram", "find_job", "generate_video", @@ -15,6 +19,21 @@ pub const SUBCOMMAND_NAMES: &[&str] = &[ #[derive(Subcommand)] #[command(rename_all = "snake_case")] pub enum Seedance2proCommand { + /// Print the account's current credit balances as one CSV line + AccountInfo(subcommands::account_info::AccountInfoArgs), + + /// Dump the account's credits ledger (credits.getCreditHistory) to CSV + /// back to a start date, for refund-entry auditing + AuditCredits(subcommands::audit_credits::AuditCreditsArgs), + + /// Dump Kinovi's per-order billing records (totalCredits) to CSV for a + /// date window, to audit whether failed orders are charged + AuditOrders(subcommands::audit_orders::AuditOrdersArgs), + + /// Dump the account's billing payments history (credit-package + /// purchases) to CSV, for reconciling refills against invoices + AuditPayments(subcommands::audit_payments::AuditPaymentsArgs), + /// Find a job by its order ID across all pages FindJob(subcommands::find_job::FindJobArgs), @@ -32,6 +51,10 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> { let state = Seedance2ProState { cookies }; match command { + Seedance2proCommand::AccountInfo(args) => subcommands::account_info::run(&state, args).await, + Seedance2proCommand::AuditCredits(args) => subcommands::audit_credits::run(&state, args).await, + Seedance2proCommand::AuditOrders(args) => subcommands::audit_orders::run(&state, args).await, + Seedance2proCommand::AuditPayments(args) => subcommands::audit_payments::run(&state, args).await, Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await, Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await, Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await, diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/account_info.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/account_info.rs new file mode 100644 index 0000000000..c5c0a787d4 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/account_info.rs @@ -0,0 +1,46 @@ +use anyhow::anyhow; +use clap::Args; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use seedance2pro_client::requests::get_user_auth_details::get_user_auth_details::{ + get_user_auth_details, GetUserAuthDetailsArgs, +}; + +use super::super::state::Seedance2ProState; + +/// Print the account's current credit balances (auth.user endpoint) as a +/// single CSV line: account,email,credits,available_credits. +#[derive(Args)] +pub struct AccountInfoArgs { + /// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES. + #[arg(long)] + pub cookies_env: Option, + + /// Account label for the output line. Defaults to the cookies env var name. + #[arg(long)] + pub account: Option, +} + +pub async fn run(state: &Seedance2ProState, args: AccountInfoArgs) -> anyhow::Result<()> { + let (cookies, account_label) = match &args.cookies_env { + Some(var) => ( + easyenv::get_env_string_required(var) + .map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?, + args.account.clone().unwrap_or_else(|| var.clone()), + ), + None => ( + state.cookies.clone(), + args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()), + ), + }; + let session = Seedance2ProSession::from_cookies_string(cookies); + + let details = get_user_auth_details(GetUserAuthDetailsArgs { + session: &session, + host_override: None, + }).await + .map_err(|err| anyhow!("Error fetching auth details: {:?}", err))?; + + println!("{},{},{},{}", + account_label, details.email, details.credits, details.available_credits); + Ok(()) +} diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_credits.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_credits.rs new file mode 100644 index 0000000000..382d97d2dd --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_credits.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::time::Duration; + +use anyhow::anyhow; +use clap::Args; +use log::info; +use seedance2pro_client::billing::get_credits_history::{ + get_credits_history, CreditHistoryEntry, GetCreditsHistoryArgs, +}; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; + +use super::super::state::Seedance2ProState; + +/// Dump the account's credits ledger (credits.getCreditHistory) to CSV, +/// newest-first, back to a start date. The ledger is offset-paginated and +/// grows while paging (new rows push older rows to higher offsets), which +/// causes re-reads rather than gaps - consumers should dedupe by entry id. +#[derive(Args)] +pub struct AuditCreditsArgs { + /// Output CSV path. Appends if the file exists. + #[arg(long)] + pub out: String, + + /// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD). + #[arg(long)] + pub start_date: Option, + + /// Page size to request (the server may cap it; paging advances by rows + /// actually returned, so a cap cannot skip rows). + #[arg(long, default_value_t = 100)] + pub limit: u32, + + /// Maximum pages to fetch this run. + #[arg(long, default_value_t = 10_000)] + pub max_pages: usize, + + /// Resume from a row offset (e.g. after an interrupted run). + #[arg(long, default_value_t = 0)] + pub offset: u64, + + /// Delay between page fetches, in milliseconds. + #[arg(long, default_value_t = 250)] + pub delay_ms: u64, + + /// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES. + #[arg(long)] + pub cookies_env: Option, + + /// Account label written into each CSV row. Defaults to the cookies env + /// var name. + #[arg(long)] + pub account: Option, +} + +pub async fn run(state: &Seedance2ProState, args: AuditCreditsArgs) -> anyhow::Result<()> { + let (cookies, account_label) = match &args.cookies_env { + Some(var) => ( + easyenv::get_env_string_required(var) + .map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?, + args.account.clone().unwrap_or_else(|| var.clone()), + ), + None => ( + state.cookies.clone(), + args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()), + ), + }; + let session = Seedance2ProSession::from_cookies_string(cookies); + + let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true); + let mut out = OpenOptions::new().create(true).append(true).open(&args.out) + .map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?; + if file_is_new { + writeln!(out, "account,entry_id,created_at,entry_type,credit_delta,order_id,product_name")?; + } + + let mut offset = args.offset; + let mut rows = 0u64; + let mut type_tallies: HashMap = HashMap::new(); + let mut last_reported_total = 0u64; + + for page_number in 1..=args.max_pages { + let page = get_credits_history(GetCreditsHistoryArgs { + session: &session, + limit: args.limit, + offset, + host_override: None, + }).await + .map_err(|err| anyhow!( + "Error fetching credits page {} (resume with --offset {}): {:?}", + page_number, offset, err))?; + + last_reported_total = page.total; + if page.entries.is_empty() { + info!("Page {} returned no entries; reached the end of the ledger.", page_number); + break; + } + + let newest_day = page.entries.first().map(|e| day_of(e)).unwrap_or_default(); + let oldest_day = page.entries.last().map(|e| day_of(e)).unwrap_or_default(); + + for entry in &page.entries { + write_entry_row(&mut out, &account_label, entry)?; + rows += 1; + let tally = type_tallies.entry(entry.entry_type.as_str().to_string()).or_insert((0, 0)); + tally.0 += 1; + tally.1 += entry.credit_delta; + } + out.flush()?; + + if page_number % 25 == 0 || page_number == 1 { + info!( + "Page {} (offset {}): {} entries ({} .. {}), written {} (server total {})", + page_number, offset, page.entries.len(), newest_day, oldest_day, rows, page.total, + ); + } + + // Newest-first: once a whole page predates the window, everything + // after it does too. + if args.start_date.as_deref().is_some_and(|start| oldest_day.as_str() < start) { + info!("Reached entries older than {}; done.", args.start_date.as_deref().unwrap()); + break; + } + + offset += page.entries.len() as u64; + tokio::time::sleep(Duration::from_millis(args.delay_ms)).await; + } + + println!("── audit_credits summary [{}] ──", account_label); + println!("entries written: {} (server reports ledger total {}; duplicates possible", rows, last_reported_total); + println!(" under live growth - dedupe by entry_id downstream)"); + let mut tallies: Vec<_> = type_tallies.iter().collect(); + tallies.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count)); + for (entry_type, (count, delta_sum)) in tallies { + println!(" {:<28} n={:<9} credit_sum={}", entry_type, count, delta_sum); + } + Ok(()) +} + +/// UTC day prefix ("YYYY-MM-DD"); ISO strings compare lexicographically. +fn day_of(entry: &CreditHistoryEntry) -> String { + entry.created_at.get(..10).unwrap_or("").to_string() +} + +fn write_entry_row(out: &mut impl Write, account: &str, entry: &CreditHistoryEntry) -> anyhow::Result<()> { + writeln!( + out, + "{},{},{},{},{},{},{}", + account, + entry.id, + entry.created_at, + entry.entry_type.as_str(), + entry.credit_delta, + entry.maybe_order_id.as_deref().unwrap_or(""), + csv_quote(&entry.product_name), + )?; + Ok(()) +} + +/// Minimal CSV quoting for the one free-text field. +fn csv_quote(field: &str) -> String { + if field.is_empty() { + return String::new(); + } + format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " ")) +} diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_orders.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_orders.rs new file mode 100644 index 0000000000..fbaa2af2a6 --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_orders.rs @@ -0,0 +1,243 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::time::Duration; + +use anyhow::anyhow; +use clap::Args; +use log::{info, warn}; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; +use seedance2pro_client::requests::poll_orders::poll_orders::{ + poll_orders, OrderStatus, PollOrdersArgs, TaskStatus, +}; + +use super::super::state::Seedance2ProState; + +/// Dump Kinovi's own per-order billing records (`totalCredits`) to CSV for a +/// date window, by paging the getOrders API newest-to-oldest. Used to audit +/// whether failed orders carry charges. +#[derive(Args)] +pub struct AuditOrdersArgs { + /// Output CSV path. Appends if the file exists (resume-friendly). + #[arg(long)] + pub out: String, + + /// Stop paging once a whole page is older than this UTC date (YYYY-MM-DD). + /// Without it, paging stops at --max_pages. + #[arg(long)] + pub start_date: Option, + + /// Skip (do not write) orders on days after this UTC date (YYYY-MM-DD, + /// inclusive). Pages still have to be fetched to reach older orders. + #[arg(long)] + pub end_date: Option, + + /// Maximum pages to fetch this run (30 orders per page). + #[arg(long, default_value_t = 200)] + pub max_pages: usize, + + /// Resume from a cursor logged by a previous run. + #[arg(long)] + pub cursor: Option, + + /// Delay between page fetches, in milliseconds. + #[arg(long, default_value_t = 250)] + pub delay_ms: u64, + + /// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES + /// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES / + /// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account). + #[arg(long)] + pub cookies_env: Option, + + /// Account label written into each CSV row. Defaults to the cookies env + /// var name (or "SEEDANCE2PRO_COOKIES"). + #[arg(long)] + pub account: Option, +} + +pub async fn run(state: &Seedance2ProState, args: AuditOrdersArgs) -> anyhow::Result<()> { + let (cookies, account_label) = match &args.cookies_env { + Some(var) => ( + easyenv::get_env_string_required(var) + .map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?, + args.account.clone().unwrap_or_else(|| var.clone()), + ), + None => ( + state.cookies.clone(), + args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()), + ), + }; + let session = Seedance2ProSession::from_cookies_string(cookies); + + let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true); + let mut out = OpenOptions::new().create(true).append(true).open(&args.out) + .map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?; + if file_is_new { + writeln!(out, "account,order_id,created_at,task_status,media_type,total_credits,result_count,fail_reason")?; + } + + let mut cursor = args.cursor; + let mut stats = AuditStats::default(); + + for page in 1..=args.max_pages { + let result = poll_orders(PollOrdersArgs { + session: &session, + cursor, + host_override: None, + }).await + .map_err(|err| anyhow!( + "Error polling page {} (resume with --cursor {:?}): {:?}", page, cursor, err))?; + + if result.orders.is_empty() { + info!("Page {} returned no orders; stopping.", page); + break; + } + + let newest = order_day(result.orders.first().unwrap()); + let oldest = order_day(result.orders.last().unwrap()); + + for order in &result.orders { + let day = order_day(order); + if args.end_date.as_deref().is_some_and(|end| day > end) { + stats.skipped_after_window += 1; + continue; + } + if args.start_date.as_deref().is_some_and(|start| day < start) { + stats.skipped_before_window += 1; + continue; + } + write_order_row(&mut out, &account_label, order)?; + stats.record(order); + } + out.flush()?; + + info!( + "Page {}: {} orders ({} .. {}), written so far: {}, next cursor: {:?}", + page, result.orders.len(), newest, oldest, stats.written, result.next_cursor, + ); + + // The API pages newest-to-oldest: once the whole page predates the + // window, everything after it will too. + if args.start_date.as_deref().is_some_and(|start| oldest < start) { + info!("Reached orders older than {}; done.", args.start_date.as_deref().unwrap()); + cursor = result.next_cursor; + break; + } + + cursor = result.next_cursor; + if cursor.is_none() { + info!("No further pages; reached the end of the account's history."); + break; + } + + tokio::time::sleep(Duration::from_millis(args.delay_ms)).await; + } + + stats.print_summary(&account_label, cursor); + Ok(()) +} + +#[derive(Default)] +struct AuditStats { + written: u64, + skipped_after_window: u64, + skipped_before_window: u64, + completed: u64, + completed_credits: u64, + completed_missing_credits: u64, + failed: u64, + failed_credits: u64, + failed_with_nonzero_credits: u64, + in_flight: u64, + in_flight_credits: u64, +} + +impl AuditStats { + fn record(&mut self, order: &OrderStatus) { + self.written += 1; + let credits = u64::from(order.total_credits.unwrap_or(0)); + match order.task_status { + TaskStatus::Completed => { + self.completed += 1; + self.completed_credits += credits; + if order.total_credits.is_none() { + self.completed_missing_credits += 1; + } + } + TaskStatus::Failed => { + self.failed += 1; + self.failed_credits += credits; + if credits > 0 { + self.failed_with_nonzero_credits += 1; + } + } + _ => { + self.in_flight += 1; + self.in_flight_credits += credits; + } + } + } + + fn print_summary(&self, account: &str, resume_cursor: Option) { + // Final results are intentionally println: they ARE the tool's output. + println!("── audit_orders summary [{}] ──", account); + println!("orders written: {}", self.written); + println!(" completed: {} ({} credits; {} missing totalCredits)", + self.completed, self.completed_credits, self.completed_missing_credits); + println!(" failed: {} ({} credits; {} orders with credits > 0)", + self.failed, self.failed_credits, self.failed_with_nonzero_credits); + println!(" pending/processing: {} ({} credits)", self.in_flight, self.in_flight_credits); + println!("skipped (newer than window): {}", self.skipped_after_window); + println!("skipped (older than window): {}", self.skipped_before_window); + match resume_cursor { + Some(c) => println!("resume cursor: {}", c), + None => println!("resume cursor: (none - history exhausted or window done)"), + } + if self.failed_with_nonzero_credits > 0 { + warn!( + "{} FAILED orders carry non-zero totalCredits ({} credits total) — \ + Kinovi's own records say failures were charged.", + self.failed_with_nonzero_credits, self.failed_credits, + ); + } + } +} + +/// UTC day prefix ("YYYY-MM-DD") of the order's ISO 8601 created_at. +/// ISO strings compare lexicographically, so no date parsing is needed. +fn order_day(order: &OrderStatus) -> &str { + order.created_at.get(..10).unwrap_or("") +} + +fn write_order_row(out: &mut impl Write, account: &str, order: &OrderStatus) -> anyhow::Result<()> { + let media_type = order.media_type.as_ref() + .map(|m| format!("{:?}", m)) + .unwrap_or_default(); + let total_credits = order.total_credits + .map(|c| c.to_string()) + .unwrap_or_default(); + let fail_reason = order.fail_reason.as_ref() + .map(|fr| fr.reason.as_str()) + .unwrap_or(""); + writeln!( + out, + "{},{},{},{:?},{},{},{},{}", + account, + order.order_id, + order.created_at, + order.task_status, + media_type, + total_credits, + order.results.len(), + csv_quote(fail_reason), + )?; + Ok(()) +} + +/// Minimal CSV quoting for the one free-text field. +fn csv_quote(field: &str) -> String { + if field.is_empty() { + return String::new(); + } + format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " ")) +} diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_payments.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_payments.rs new file mode 100644 index 0000000000..d2679b2c7e --- /dev/null +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/audit_payments.rs @@ -0,0 +1,131 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::time::Duration; + +use anyhow::anyhow; +use clap::Args; +use log::info; +use seedance2pro_client::billing::get_billing_payments_history::{ + get_billing_payments_history, BillingPayment, GetBillingPaymentsHistoryArgs, + DEFAULT_BILLING_PAGE_LIMIT, +}; +use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; + +use super::super::state::Seedance2ProState; + +/// Dump the account's full billing payments history (credit-package +/// purchases) to CSV, for reconciling refill charges against invoices. +#[derive(Args)] +pub struct AuditPaymentsArgs { + /// Output CSV path. Appends if the file exists. + #[arg(long)] + pub out: String, + + /// Maximum pages to fetch (20 payments per page). + #[arg(long, default_value_t = 500)] + pub max_pages: usize, + + /// Delay between page fetches, in milliseconds. + #[arg(long, default_value_t = 250)] + pub delay_ms: u64, + + /// Read session cookies from this env var instead of SEEDANCE2PRO_COOKIES. + #[arg(long)] + pub cookies_env: Option, + + /// Account label written into each CSV row. Defaults to the cookies env + /// var name. + #[arg(long)] + pub account: Option, +} + +pub async fn run(state: &Seedance2ProState, args: AuditPaymentsArgs) -> anyhow::Result<()> { + let (cookies, account_label) = match &args.cookies_env { + Some(var) => ( + easyenv::get_env_string_required(var) + .map_err(|err| anyhow!("Missing {} env var: {:?}", var, err))?, + args.account.clone().unwrap_or_else(|| var.clone()), + ), + None => ( + state.cookies.clone(), + args.account.clone().unwrap_or_else(|| "SEEDANCE2PRO_COOKIES".to_string()), + ), + }; + let session = Seedance2ProSession::from_cookies_string(cookies); + + let file_is_new = std::fs::metadata(&args.out).map(|m| m.len() == 0).unwrap_or(true); + let mut out = OpenOptions::new().create(true).append(true).open(&args.out) + .map_err(|err| anyhow!("Cannot open {}: {}", args.out, err))?; + if file_is_new { + writeln!(out, "account,payment_id,created_at,amount_usd,credits_earned,status,payment_type,product_name,transaction_id")?; + } + + let mut offset: u64 = 0; + let mut rows = 0u64; + let mut total_amount = 0f64; + let mut total_credits = 0u64; + let mut last_reported_total = 0u64; + + for page_number in 1..=args.max_pages { + let page = get_billing_payments_history(GetBillingPaymentsHistoryArgs { + session: &session, + limit: DEFAULT_BILLING_PAGE_LIMIT, + offset, + host_override: None, + }).await + .map_err(|err| anyhow!( + "Error fetching payments page {} (offset {}): {:?}", page_number, offset, err))?; + + last_reported_total = page.total; + if page.payments.is_empty() { + break; + } + + for payment in &page.payments { + write_payment_row(&mut out, &account_label, payment)?; + rows += 1; + total_amount += payment.amount_usd; + total_credits += payment.credits_earned; + } + out.flush()?; + + info!( + "Page {} (offset {}): {} payments; running ${:.2} / {} credits (server total {})", + page_number, offset, page.payments.len(), total_amount, total_credits, page.total, + ); + + offset += page.payments.len() as u64; + tokio::time::sleep(Duration::from_millis(args.delay_ms)).await; + } + + println!("── audit_payments summary [{}] ──", account_label); + println!("payments written: {} (server reports total {})", rows, last_reported_total); + println!("lifetime charged: ${:.2}", total_amount); + println!("lifetime credits: {}", total_credits); + Ok(()) +} + +fn write_payment_row(out: &mut impl Write, account: &str, payment: &BillingPayment) -> anyhow::Result<()> { + writeln!( + out, + "{},{},{},{},{},{:?},{:?},{},{}", + account, + payment.id, + payment.created_at, + payment.amount_usd, + payment.credits_earned, + payment.status, + payment.payment_type, + csv_quote(&payment.product_name), + payment.transaction_id, + )?; + Ok(()) +} + +/// Minimal CSV quoting for the one free-text field. +fn csv_quote(field: &str) -> String { + if field.is_empty() { + return String::new(); + } + format!("\"{}\"", field.replace('"', "\"\"").replace('\n', " ")) +} diff --git a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/mod.rs b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/mod.rs index be91d818a4..323f25fe08 100644 --- a/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/mod.rs +++ b/crates/cli/production/support-tool/src/commands/seedance2pro/subcommands/mod.rs @@ -1,3 +1,7 @@ +pub mod account_info; +pub mod audit_credits; +pub mod audit_orders; +pub mod audit_payments; pub mod failed_job_histogram; pub mod find_job; pub mod generate_video;