mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 17:28:24 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d20537f3a | ||
|
|
029cd5a836 | ||
|
|
ce6c652669 | ||
|
|
5858f7cc5d | ||
|
|
99d7da8197 |
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+46
@@ -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<String>,
|
||||
|
||||
/// Account label for the output line. Defaults to the cookies env var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
+167
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
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<String, (u64, i64)> = 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', " "))
|
||||
}
|
||||
+243
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<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
|
||||
/// (e.g. SEEDANCE2PRO_VOLCENGINE_COOKIES / SEEDANCE2PRO_BYTEPLUS_COOKIES /
|
||||
/// SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES to audit each Kinovi account).
|
||||
#[arg(long)]
|
||||
pub cookies_env: Option<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name (or "SEEDANCE2PRO_COOKIES").
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
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<u64>) {
|
||||
// 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', " "))
|
||||
}
|
||||
+131
@@ -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<String>,
|
||||
|
||||
/// Account label written into each CSV row. Defaults to the cookies env
|
||||
/// var name.
|
||||
#[arg(long)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
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', " "))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user