mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
billing APIs
This commit is contained in:
@@ -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<KinoviHost>,
|
||||
}
|
||||
|
||||
pub struct BillingPaymentsHistoryPage {
|
||||
/// Payments on this page, newest first.
|
||||
pub payments: Vec<BillingPayment>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<DateTime<Utc>>,
|
||||
|
||||
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<BillingPaymentsHistoryPage, Seedance2ProError> {
|
||||
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<BillingPaymentsHistoryPage, Seedance2ProError> {
|
||||
let batch_response: Vec<BatchResponseItem> = 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<RawPayment>,
|
||||
#[serde(default)]
|
||||
next_cursor: Option<String>,
|
||||
#[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<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<KinoviHost>,
|
||||
}
|
||||
|
||||
pub struct CreditsHistoryPage {
|
||||
/// Ledger entries on this page, newest first.
|
||||
pub entries: Vec<CreditHistoryEntry>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 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<CreditsHistoryPage, Seedance2ProError> {
|
||||
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<CreditsHistoryPage, Seedance2ProError> {
|
||||
let batch_response: Vec<BatchResponseItem> = 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<RawCreditEntry>,
|
||||
#[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<String>,
|
||||
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<u64> = HashSet::new();
|
||||
let mut type_counts: HashMap<CreditHistoryEntryType, (u64, i64)> = 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<Seedance2ProSession> {
|
||||
let cookies = get_test_cookies()?;
|
||||
Ok(Seedance2ProSession::from_cookies_string(cookies))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_billing_payments_history;
|
||||
pub mod get_credits_history;
|
||||
@@ -2,6 +2,7 @@
|
||||
pub (crate) mod test_utils;
|
||||
|
||||
// User API
|
||||
pub mod billing;
|
||||
pub mod cost;
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<u64, D::Error>
|
||||
@@ -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<i64, D::Error>
|
||||
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<Option<u64>, D::Error>
|
||||
@@ -90,6 +114,12 @@ mod tests {
|
||||
value: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct I64Holder {
|
||||
#[serde(deserialize_with = "de_i64_int_or_float")]
|
||||
value: i64,
|
||||
}
|
||||
|
||||
fn parse_u32(json: &str) -> u32 {
|
||||
serde_json::from_str::<U32Holder>(json).unwrap().value
|
||||
}
|
||||
@@ -98,6 +128,10 @@ mod tests {
|
||||
serde_json::from_str::<U64Holder>(json).unwrap().value
|
||||
}
|
||||
|
||||
fn parse_i64(json: &str) -> i64 {
|
||||
serde_json::from_str::<I64Holder>(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::<OptU32Holder>(r#"{"value": null}"#).unwrap().value, None);
|
||||
|
||||
Reference in New Issue
Block a user