diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 9d6a84cf..860614db 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -175,6 +175,7 @@ jobs: DEPLOY_PATHS=( lit-actions/ lit-api-server/ + lit-billing-core/ lit-core/ otel-collector/ Dockerfile.lit-actions diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index dc3ccf2f..48982df3 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -29,6 +29,7 @@ jobs: - lit-core - lit-api-server - lit-api-server/blockchain/rust_generator_and_deployer + - lit-billing-core steps: - uses: actions/checkout@v4 diff --git a/lit-api-server/Cargo.lock b/lit-api-server/Cargo.lock index 9f3e4c08..8ddcbc6f 100644 --- a/lit-api-server/Cargo.lock +++ b/lit-api-server/Cargo.lock @@ -4618,6 +4618,7 @@ dependencies = [ "k256", "lit-actions-grpc", "lit-api-core", + "lit-billing-core", "lit-core", "lit-core-derive", "lit-observability", @@ -4651,6 +4652,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "lit-billing-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest 0.12.28", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "lit-core" version = "0.1.0" diff --git a/lit-api-server/Cargo.toml b/lit-api-server/Cargo.toml index 8ec71e4e..8ed1fce0 100644 --- a/lit-api-server/Cargo.toml +++ b/lit-api-server/Cargo.toml @@ -35,6 +35,7 @@ sha3 = "0.10" ipfs-hasher = "0.13.0" elliptic-curve = "0.13.8" k256 = { version = "0.13.4", features = ["ecdsa"] } +lit-billing-core = { path = "../lit-billing-core" } lit-core = { path = "../lit-core/lit-core" } lit-core-derive = { path = "../lit-core/lit-core-derive" } lit-observability = { path = "../lit-core/lit-observability", features = ["channels"] } diff --git a/lit-api-server/src/stripe.rs b/lit-api-server/src/stripe.rs index 30288581..c12f9daf 100644 --- a/lit-api-server/src/stripe.rs +++ b/lit-api-server/src/stripe.rs @@ -8,14 +8,26 @@ /// /// Customer identity: the Stripe customer is keyed by the wallet address derived from the API key /// (stored in customer metadata as `wallet_address`). +/// +/// The raw Stripe HTTP client + customer/balance/reporting primitives live in +/// `lit-billing-core` so the same identity model is shared with `lit-payments`. +/// This module wraps the core client with the in-process caches and the +/// charge / PaymentIntent flows specific to the API server. use std::sync::Arc; use std::time::Duration; use anyhow::Result; +use lit_billing_core::StripeClient; use moka::future::Cache; -use reqwest::StatusCode; use tracing::instrument; +// Re-export the bits of lit-billing-core that out-of-crate callers (bin/stripe_report.rs, +// other modules in lit-api-server) reference via `lit_api_server::stripe::*`. +pub use lit_billing_core::format::{cents_to_display, unix_to_utc_date}; +pub use lit_billing_core::reporting::{ + ReportBalanceTx, ReportCustomer, ReportRow, aggregate_report_rows, +}; + /// Cost constants in US cents. pub const COST_MANAGEMENT_CENTS: i64 = 1; // $0.01 pub const COST_LIT_ACTION_PER_SECOND_CENTS: i64 = 1; // $0.01 per second of execution @@ -24,12 +36,10 @@ pub const MIN_TOPUP_CENTS: i64 = 500; // ─── State ──────────────────────────────────────────────────────────────────── -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct StripeState { - // NOTE: Debug is implemented manually below to redact secret_key. pub publishable_key: String, - secret_key: String, - client: reqwest::Client, + client: StripeClient, /// wallet_address → Stripe customer ID cache (10-min idle timeout). /// Avoids duplicate customer creation caused by Stripe Search API indexing lag. /// Uses `time_to_idle` so frequently accessed entries stay warm. @@ -50,15 +60,6 @@ pub struct StripeState { balance_refresh_in_flight: Cache, } -impl std::fmt::Debug for StripeState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StripeState") - .field("publishable_key", &self.publishable_key) - .field("secret_key", &"[REDACTED]") - .finish() - } -} - /// Initialise Stripe from environment variables. Returns `None` if the env vars are absent /// (billing disabled — all charges are skipped). pub fn init() -> Option> { @@ -67,9 +68,7 @@ pub fn init() -> Option> { if secret_key.is_empty() || publishable_key.is_empty() { return None; } - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() + let client = StripeClient::new(secret_key) .map_err(|e| tracing::error!("stripe: failed to build HTTP client: {e}")) .ok()?; let customer_cache = Cache::builder() @@ -91,7 +90,6 @@ pub fn init() -> Option> { tracing::info!("stripe: billing enabled"); Some(Arc::new(StripeState { publishable_key, - secret_key, client, customer_cache, wallet_cache, @@ -100,100 +98,6 @@ pub fn init() -> Option> { })) } -// ─── Stripe API helpers ─────────────────────────────────────────────────────── - -fn stripe_base() -> &'static str { - "https://api.stripe.com/v1" -} - -/// Parsed Stripe API response preserving the HTTP status code. -#[derive(Debug)] -pub struct StripeResponse { - pub status: StatusCode, - pub body: serde_json::Value, -} - -/// Parse a Stripe API response from raw status + body text. -/// -/// Accepts `(StatusCode, &str)` rather than `reqwest::Response` so this logic -/// is trivially unit-testable without mocking HTTP. -fn parse_stripe_response(status: StatusCode, body_text: &str) -> Result { - let body: serde_json::Value = serde_json::from_str(body_text) - .map_err(|e| anyhow::anyhow!("Stripe: invalid JSON (HTTP {status}): {e}"))?; - - if let Some(e) = body.get("error") { - let msg = e - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or("unknown error"); - anyhow::bail!("Stripe error (HTTP {status}): {msg}"); - } - - Ok(StripeResponse { status, body }) -} - -/// `GET /v1/` with optional query params. -async fn stripe_get( - state: &StripeState, - path: &str, - query: &[(&str, &str)], -) -> Result { - let url = format!("{}/{}", stripe_base(), path); - let resp = state - .client - .get(&url) - .basic_auth(&state.secret_key, Some("")) - .query(query) - .send() - .await?; - let status = resp.status(); - let body_text = resp.text().await?; - parse_stripe_response(status, &body_text) -} - -/// `POST /v1/` with form-encoded body. -async fn stripe_post( - state: &StripeState, - path: &str, - params: &[(&str, &str)], -) -> Result { - let url = format!("{}/{}", stripe_base(), path); - let resp = state - .client - .post(&url) - .basic_auth(&state.secret_key, Some("")) - .form(params) - .send() - .await?; - let status = resp.status(); - let body_text = resp.text().await?; - parse_stripe_response(status, &body_text) -} - -/// `POST /v1/` with form-encoded body and an `Idempotency-Key` header. -/// -/// Stripe deduplicates requests sharing the same key within 24 hours, making -/// retries after network errors safe from producing duplicate side-effects. -async fn stripe_post_with_idempotency( - state: &StripeState, - path: &str, - params: &[(&str, &str)], - idempotency_key: &str, -) -> Result { - let url = format!("{}/{}", stripe_base(), path); - let resp = state - .client - .post(&url) - .basic_auth(&state.secret_key, Some("")) - .header("Idempotency-Key", idempotency_key) - .form(params) - .send() - .await?; - let status = resp.status(); - let body_text = resp.text().await?; - parse_stripe_response(status, &body_text) -} - // ─── Public API ─────────────────────────────────────────────────────────────── /// Compute a non-sensitive cache key from an account identity string. @@ -268,35 +172,7 @@ pub async fn get_customer_by_wallet(wallet_address: &str, state: &StripeState) - state .customer_cache .try_get_with(wallet.clone(), async { - // Search by metadata. - let query = format!("metadata['wallet_address']:'{wallet}'"); - let resp = stripe_get( - &state, - "customers/search", - &[("query", query.as_str()), ("limit", "1")], - ) - .await?; - - if let Some(data) = resp.body.get("data").and_then(|d| d.as_array()) - && let Some(first) = data.first() - && let Some(id) = first.get("id").and_then(|i| i.as_str()) - { - return Ok(id.to_string()); - }; - - // Not found, create a new customer - let resp = stripe_post( - &state, - "customers", - &[("metadata[wallet_address]", &wallet)], - ) - .await?; - let id = resp - .body - .get("id") - .and_then(|i| i.as_str()) - .ok_or_else(|| anyhow::anyhow!("Stripe: missing customer id"))?; - Ok(id.to_string()) + lit_billing_core::customer::find_or_create_by_wallet(&state.client, &wallet).await }) .await .map_err(|e: Arc| anyhow::anyhow!("{e}")) @@ -323,7 +199,7 @@ pub async fn get_credit_balance(customer_id: &str, state: &StripeState) -> Resul let state = state.clone(); let cid2 = cid.clone(); tokio::spawn(async move { - match fetch_balance(&state, &cid2).await { + match lit_billing_core::balance::fetch(&state.client, &cid2).await { Ok(fetched) => { let current = state.balance_cache.get(&cid2).await; if should_update_balance_cache(current, fetched) { @@ -348,7 +224,9 @@ pub async fn get_credit_balance(customer_id: &str, state: &StripeState) -> Resul let cid2 = cid.clone(); state .balance_cache - .try_get_with(cid, async move { fetch_balance(&state2, &cid2).await }) + .try_get_with(cid, async move { + lit_billing_core::balance::fetch(&state2.client, &cid2).await + }) .await .map_err(|e: Arc| anyhow::anyhow!("{e}")) } @@ -372,18 +250,6 @@ fn should_update_balance_cache(cached: Option, fetched: i64) -> bool { } } -/// Fetch the raw balance from Stripe for a given customer ID. -async fn fetch_balance(state: &StripeState, customer_id: &str) -> Result { - let resp = stripe_get(state, &format!("customers/{customer_id}"), &[]).await?; - let balance = resp - .body - .get("balance") - .and_then(|b| b.as_i64()) - .unwrap_or(0); - tracing::debug!(customer_id, balance, "stripe::get_credit_balance: done"); - Ok(balance) -} - /// Charge `cost_cents` against the customer's credit balance. /// /// Reads the cached balance directly (without triggering a background refresh) to @@ -412,7 +278,7 @@ async fn charge(api_key: &str, cost_cents: i64, state: &StripeState) -> Result<( state .balance_cache .try_get_with(customer_id.clone(), async move { - fetch_balance(&state2, &cid).await + lit_billing_core::balance::fetch(&state2.client, &cid).await }) .await .map_err(|e: Arc| anyhow::anyhow!("{e}"))? @@ -456,17 +322,18 @@ async fn charge(api_key: &str, cost_cents: i64, state: &StripeState) -> Result<( if !delay.is_zero() { tokio::time::sleep(delay).await; } - match stripe_post_with_idempotency( - &state, - &format!("customers/{cid}/balance_transactions"), - &[ - ("amount", cost_str.as_str()), - ("currency", "usd"), - ("description", "API call charge"), - ], - &idempotency_key, - ) - .await + match state + .client + .post_with_idempotency( + &format!("customers/{cid}/balance_transactions"), + &[ + ("amount", cost_str.as_str()), + ("currency", "usd"), + ("description", "API call charge"), + ], + &idempotency_key, + ) + .await { Ok(_) => return, Err(e) => { @@ -530,17 +397,18 @@ pub async fn create_payment_intent( let customer_id = get_customer_by_wallet(wallet_address, state).await?; let amount_str = amount_cents.to_string(); - let resp = stripe_post( - state, - "payment_intents", - &[ - ("amount", amount_str.as_str()), - ("currency", "usd"), - ("customer", &customer_id), - ("payment_method_types[]", "card"), - ], - ) - .await?; + let resp = state + .client + .post( + "payment_intents", + &[ + ("amount", amount_str.as_str()), + ("currency", "usd"), + ("customer", &customer_id), + ("payment_method_types[]", "card"), + ], + ) + .await?; let pi_id = resp .body @@ -573,7 +441,10 @@ pub async fn confirm_payment_and_credit( wallet_address: &str, state: &StripeState, ) -> Result<()> { - let resp = stripe_get(state, &format!("payment_intents/{payment_intent_id}"), &[]).await?; + let resp = state + .client + .get(&format!("payment_intents/{payment_intent_id}"), &[]) + .await?; let pi_status = resp .body @@ -618,24 +489,26 @@ pub async fn confirm_payment_and_credit( // Mark as credited before creating the balance transaction so that any subsequent // call with the same intent ID is rejected even if the process crashes after this point. - stripe_post( - state, - &format!("payment_intents/{payment_intent_id}"), - &[("metadata[credited]", "true")], - ) - .await?; + state + .client + .post( + &format!("payment_intents/{payment_intent_id}"), + &[("metadata[credited]", "true")], + ) + .await?; let credit = (-amount).to_string(); // negative = credit to customer - stripe_post( - state, - &format!("customers/{customer_id}/balance_transactions"), - &[ - ("amount", credit.as_str()), - ("currency", "usd"), - ("description", &format!("Top-up via {payment_intent_id}")), - ], - ) - .await?; + state + .client + .post( + &format!("customers/{customer_id}/balance_transactions"), + &[ + ("amount", credit.as_str()), + ("currency", "usd"), + ("description", &format!("Top-up via {payment_intent_id}")), + ], + ) + .await?; // Invalidate the cached balance so the customer sees updated credits immediately. state.balance_cache.invalidate(&customer_id).await; @@ -645,13 +518,7 @@ pub async fn confirm_payment_and_credit( /// Set (or update) the email on an existing Stripe customer. pub async fn set_customer_email(customer_id: &str, email: &str, state: &StripeState) -> Result<()> { - stripe_post( - state, - &format!("customers/{customer_id}"), - &[("email", email.trim())], - ) - .await?; - Ok(()) + lit_billing_core::customer::set_email(&state.client, customer_id, email).await } /// Best-effort: set the customer's email in Stripe. Never fails the caller. @@ -665,102 +532,15 @@ pub async fn register_customer_email(wallet_address: &str, email: &str, state: & let _ = set_customer_email(&customer_id, email.trim(), state).await; } -/// Format cents as a display string, e.g. 500 → "$5.00". -pub fn cents_to_display(cents: i64) -> String { - format!("${}.{:02}", cents / 100, cents.abs() % 100) -} - // ─── Reporting helpers ──────────────────────────────────────────────────────── // -// These helpers power the `stripe_report` binary. They are not used by the -// running API server — they are pub so that other crates in the workspace -// (and the binary) can reuse the authenticated HTTP client and the same -// customer-to-wallet mapping convention. - -/// One Stripe customer as returned by `list_customers`. -#[derive(Debug, Clone)] -pub struct ReportCustomer { - pub id: String, - pub wallet_address: Option, - pub email: Option, -} - -/// One customer balance transaction as returned by `list_balance_transactions_since`. -/// -/// `created` is a Unix timestamp in seconds. `amount` is in the currency's minor unit -/// (cents for USD): positive = charge (debit to the customer's credit balance), -/// negative = credit (top-up). -#[derive(Debug, Clone)] -pub struct ReportBalanceTx { - pub id: String, - pub customer_id: String, - pub amount: i64, - pub created: i64, - pub description: String, -} +// Thin wrappers over `lit-billing-core::reporting` that take the cached +// `StripeState` so call sites in `bin/stripe_report.rs` don't need to know +// about the inner `StripeClient`. /// Page over `GET /v1/customers` and return every customer, 100 at a time. pub async fn list_all_customers(state: &StripeState) -> Result> { - let mut out = Vec::new(); - let mut starting_after: Option = None; - loop { - let mut query: Vec<(&str, &str)> = vec![("limit", "100")]; - if let Some(cursor) = starting_after.as_deref() { - query.push(("starting_after", cursor)); - } - let resp = stripe_get(state, "customers", &query).await?; - let data = resp - .body - .get("data") - .and_then(|d| d.as_array()) - .cloned() - .unwrap_or_default(); - if data.is_empty() { - break; - } - for c in &data { - let Some(id) = c.get("id").and_then(|v| v.as_str()) else { - continue; - }; - let wallet = c - .get("metadata") - .and_then(|m| m.get("wallet_address")) - .and_then(|v| v.as_str()) - .filter(|s| !s.trim().is_empty()) - .map(|s| s.to_string()); - let email = c - .get("email") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - out.push(ReportCustomer { - id: id.to_string(), - wallet_address: wallet, - email, - }); - } - let has_more = resp - .body - .get("has_more") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !has_more { - break; - } - // Cursor must come from the raw response's last item, not from `out`. - // If every item in a page failed the id guard above, `out.last()` would - // not advance and we would re-request the same page forever. - let next_cursor = data - .last() - .and_then(|c| c.get("id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - match next_cursor { - Some(c) => starting_after = Some(c), - None => break, - } - } - Ok(out) + lit_billing_core::reporting::list_all_customers(&state.client).await } /// Fetch all customer balance transactions created at or after `since_unix` @@ -770,244 +550,18 @@ pub async fn list_balance_transactions_since( customer_id: &str, since_unix: i64, ) -> Result> { - let path = format!("customers/{customer_id}/balance_transactions"); - let since_str = since_unix.to_string(); - let mut out = Vec::new(); - let mut starting_after: Option = None; - loop { - let mut query: Vec<(&str, &str)> = - vec![("limit", "100"), ("created[gte]", since_str.as_str())]; - if let Some(cursor) = starting_after.as_deref() { - query.push(("starting_after", cursor)); - } - let resp = stripe_get(state, &path, &query).await?; - let data = resp - .body - .get("data") - .and_then(|d| d.as_array()) - .cloned() - .unwrap_or_default(); - if data.is_empty() { - break; - } - for tx in &data { - let Some(id) = tx.get("id").and_then(|v| v.as_str()) else { - continue; - }; - // Don't silently default missing amount/created to 0 — that would - // bucket malformed transactions into 1970-01-01 with $0 and skew - // the report. Skip with a warning instead. - let Some(amount) = tx.get("amount").and_then(|v| v.as_i64()) else { - tracing::warn!( - "stripe_report: skipping tx {id} for customer {customer_id}: missing/invalid 'amount' field" - ); - continue; - }; - let Some(created) = tx.get("created").and_then(|v| v.as_i64()) else { - tracing::warn!( - "stripe_report: skipping tx {id} for customer {customer_id}: missing/invalid 'created' field" - ); - continue; - }; - let description = tx - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - out.push(ReportBalanceTx { - id: id.to_string(), - customer_id: customer_id.to_string(), - amount, - created, - description, - }); - } - let has_more = resp - .body - .get("has_more") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !has_more { - break; - } - // See note in list_all_customers: derive the cursor from the raw response, - // not from `out`, so a fully-filtered page can't stall the loop. - let next_cursor = data - .last() - .and_then(|c| c.get("id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - match next_cursor { - Some(c) => starting_after = Some(c), - None => break, - } - } - Ok(out) -} - -/// Convert a Unix timestamp (seconds, UTC) to a `YYYY-MM-DD` date string. -/// -/// Pure function — no external deps (avoids pulling `chrono` into the server crate). -pub fn unix_to_utc_date(ts: i64) -> String { - // Days since 1970-01-01 (Thursday), floor. - let days = ts.div_euclid(86_400); - // Convert to (year, month, day) using Howard Hinnant's civil_from_days algorithm. - // https://howardhinnant.github.io/date_algorithms.html#civil_from_days - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; // [0, 146096] - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] - let year = if m <= 2 { y + 1 } else { y }; - format!("{year:04}-{m:02}-{d:02}") -} - -/// One row of the per-day-per-client usage report. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReportRow { - pub date: String, - pub customer_id: String, - pub wallet_address: Option, - pub email: Option, - /// Number of positive-amount balance transactions (charges) on this day. - pub charges_count: u64, - /// Sum of positive amounts in cents (debits to the customer's credit balance). - pub charges_cents: i64, - /// Sum of absolute value of negative amounts in cents (credits / top-ups). - pub credits_cents: i64, -} - -/// Aggregate a flat list of balance transactions into one row per -/// (date, customer_id) pair. -/// -/// `customers` provides wallet/email lookup by customer id. Transactions whose -/// `customer_id` is not present in `customers` are still bucketed but have -/// `wallet_address` and `email` set to `None`. -pub fn aggregate_report_rows( - customers: &[ReportCustomer], - transactions: &[ReportBalanceTx], -) -> Vec { - use std::collections::BTreeMap; - let customer_by_id: std::collections::HashMap<&str, &ReportCustomer> = - customers.iter().map(|c| (c.id.as_str(), c)).collect(); - // BTreeMap so output is sorted by (date, customer_id) deterministically. - let mut buckets: BTreeMap<(String, String), ReportRow> = BTreeMap::new(); - for tx in transactions { - let date = unix_to_utc_date(tx.created); - let key = (date.clone(), tx.customer_id.clone()); - let row = buckets.entry(key).or_insert_with(|| { - let cust = customer_by_id.get(tx.customer_id.as_str()).copied(); - ReportRow { - date: date.clone(), - customer_id: tx.customer_id.clone(), - wallet_address: cust.and_then(|c| c.wallet_address.clone()), - email: cust.and_then(|c| c.email.clone()), - charges_count: 0, - charges_cents: 0, - credits_cents: 0, - } - }); - if tx.amount > 0 { - row.charges_count += 1; - row.charges_cents += tx.amount; - } else if tx.amount < 0 { - row.credits_cents += -tx.amount; - } - } - buckets.into_values().collect() + lit_billing_core::reporting::list_balance_transactions_since( + &state.client, + customer_id, + since_unix, + ) + .await } #[cfg(test)] mod tests { use super::*; - #[test] - fn parse_stripe_response_2xx_success() { - let body = r#"{"id": "cus_123", "object": "customer"}"#; - let resp = parse_stripe_response(StatusCode::OK, body).unwrap(); - assert_eq!(resp.status, StatusCode::OK); - assert_eq!(resp.body["id"], "cus_123"); - } - - #[test] - fn parse_stripe_response_4xx_with_error() { - let body = - r#"{"error": {"message": "Invalid API Key provided", "type": "authentication_error"}}"#; - let err = parse_stripe_response(StatusCode::UNAUTHORIZED, body).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("HTTP 401"), "expected HTTP 401 in: {msg}"); - assert!( - msg.contains("Invalid API Key provided"), - "expected error message in: {msg}" - ); - } - - #[test] - fn parse_stripe_response_5xx_with_error() { - let body = r#"{"error": {"message": "Internal server error", "type": "api_error"}}"#; - let err = parse_stripe_response(StatusCode::INTERNAL_SERVER_ERROR, body).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("HTTP 500"), "expected HTTP 500 in: {msg}"); - } - - #[test] - fn parse_stripe_response_error_without_message() { - let body = r#"{"error": {"type": "api_error"}}"#; - let err = parse_stripe_response(StatusCode::BAD_REQUEST, body).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("unknown error"), - "expected 'unknown error' in: {msg}" - ); - } - - #[test] - fn parse_stripe_response_non_json() { - let body = "Bad Gateway"; - let err = parse_stripe_response(StatusCode::BAD_GATEWAY, body).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("invalid JSON"), - "expected 'invalid JSON' in: {msg}" - ); - assert!(msg.contains("HTTP 502"), "expected HTTP 502 in: {msg}"); - } - - #[test] - fn parse_stripe_response_2xx_with_no_error_field() { - let body = r#"{"balance": -500, "currency": "usd"}"#; - let resp = parse_stripe_response(StatusCode::OK, body).unwrap(); - assert_eq!(resp.body["balance"], -500); - } - - #[test] - fn cents_to_display_whole_dollars() { - assert_eq!(cents_to_display(500), "$5.00"); - assert_eq!(cents_to_display(100), "$1.00"); - assert_eq!(cents_to_display(0), "$0.00"); - } - - #[test] - fn cents_to_display_with_cents() { - assert_eq!(cents_to_display(199), "$1.99"); - assert_eq!(cents_to_display(1), "$0.01"); - assert_eq!(cents_to_display(50), "$0.50"); - } - - #[test] - fn cents_to_display_negative() { - // NOTE: cents_to_display has a known sign-loss bug for values in -99..=-1: - // integer division truncates toward zero, so -1/100 = 0, losing the minus sign. - // Also, the format "$-5.00" is non-standard (convention is "-$5.00"). - // These assertions document the CURRENT behavior, not the ideal behavior. - assert_eq!(cents_to_display(-500), "$-5.00"); - assert_eq!(cents_to_display(-1), "$0.01"); // BUG: should indicate negative - } - #[test] fn cache_key_deterministic() { let k1 = cache_key("test-api-key"); @@ -1030,6 +584,7 @@ mod tests { let hash_hex = format!("0x{:064x}", api_key_hash(raw)); assert_eq!(cache_key(raw), cache_key(&hash_hex)); } + // ── Balance cache merge logic ──────────────────────────────────────────── #[test] @@ -1063,99 +618,4 @@ mod tests { // Multiple charges: cache decremented to -950, Stripe still at -1000. assert!(!should_update_balance_cache(Some(-950), -1000)); } - - // ── Reporting helpers ──────────────────────────────────────────────────── - - #[test] - fn unix_to_utc_date_epoch() { - assert_eq!(unix_to_utc_date(0), "1970-01-01"); - } - - #[test] - fn unix_to_utc_date_known_values() { - // 2026-04-21 00:00:00 UTC = 1_776_729_600 - assert_eq!(unix_to_utc_date(1_776_729_600), "2026-04-21"); - // 2026-04-21 23:59:59 UTC - assert_eq!(unix_to_utc_date(1_776_729_600 + 86_399), "2026-04-21"); - // 2026-04-22 00:00:00 UTC - assert_eq!(unix_to_utc_date(1_776_729_600 + 86_400), "2026-04-22"); - } - - #[test] - fn unix_to_utc_date_leap_year() { - // 2024-02-29 is a leap day. 1709164800 = 2024-02-29 00:00:00 UTC - assert_eq!(unix_to_utc_date(1_709_164_800), "2024-02-29"); - assert_eq!(unix_to_utc_date(1_709_251_199), "2024-02-29"); - assert_eq!(unix_to_utc_date(1_709_251_200), "2024-03-01"); - } - - fn tx(customer_id: &str, amount: i64, created: i64) -> ReportBalanceTx { - ReportBalanceTx { - id: format!("tx_{customer_id}_{created}_{amount}"), - customer_id: customer_id.to_string(), - amount, - created, - description: String::new(), - } - } - - #[test] - fn aggregate_report_rows_empty() { - assert!(aggregate_report_rows(&[], &[]).is_empty()); - } - - #[test] - fn aggregate_report_rows_buckets_by_day_and_customer() { - let customers = vec![ - ReportCustomer { - id: "cus_a".to_string(), - wallet_address: Some("0xA".to_string()), - email: None, - }, - ReportCustomer { - id: "cus_b".to_string(), - wallet_address: Some("0xB".to_string()), - email: Some("b@example.com".to_string()), - }, - ]; - let day1 = 1_776_729_600; // 2026-04-21 00:00:00 UTC - let day2 = day1 + 86_400; // 2026-04-22 00:00:00 UTC - let txs = vec![ - tx("cus_a", 1, day1 + 10), - tx("cus_a", 1, day1 + 20), - tx("cus_a", 1, day2 + 5), - tx("cus_b", 5, day1 + 1), - tx("cus_b", -500, day1 + 2), // top-up credit - ]; - let rows = aggregate_report_rows(&customers, &txs); - assert_eq!(rows.len(), 3); - // Sorted by (date, customer_id) ascending. - assert_eq!(rows[0].date, "2026-04-21"); - assert_eq!(rows[0].customer_id, "cus_a"); - assert_eq!(rows[0].charges_count, 2); - assert_eq!(rows[0].charges_cents, 2); - assert_eq!(rows[0].credits_cents, 0); - assert_eq!(rows[0].wallet_address.as_deref(), Some("0xA")); - assert_eq!(rows[1].date, "2026-04-21"); - assert_eq!(rows[1].customer_id, "cus_b"); - assert_eq!(rows[1].charges_count, 1); - assert_eq!(rows[1].charges_cents, 5); - assert_eq!(rows[1].credits_cents, 500); - assert_eq!(rows[1].email.as_deref(), Some("b@example.com")); - assert_eq!(rows[2].date, "2026-04-22"); - assert_eq!(rows[2].customer_id, "cus_a"); - assert_eq!(rows[2].charges_count, 1); - } - - #[test] - fn aggregate_report_rows_unknown_customer_still_bucketed() { - let day1 = 1_776_729_600; // 2026-04-21 00:00:00 UTC - let txs = vec![tx("cus_unknown", 3, day1)]; - let rows = aggregate_report_rows(&[], &txs); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].customer_id, "cus_unknown"); - assert_eq!(rows[0].wallet_address, None); - assert_eq!(rows[0].email, None); - assert_eq!(rows[0].charges_cents, 3); - } } diff --git a/lit-billing-core/.gitignore b/lit-billing-core/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/lit-billing-core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/lit-billing-core/Cargo.lock b/lit-billing-core/Cargo.lock new file mode 100644 index 00000000..73a0834e --- /dev/null +++ b/lit-billing-core/Cargo.lock @@ -0,0 +1,1649 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lit-billing-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/lit-billing-core/Cargo.toml b/lit-billing-core/Cargo.toml new file mode 100644 index 00000000..b8488cae --- /dev/null +++ b/lit-billing-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lit-billing-core" +version = "0.1.0" +edition = "2024" +description = "Shared Stripe client + customer/balance primitives for Lit billing services" +license = "Apache-2.0" + +[dependencies] +anyhow = "1.0" +reqwest = { version = "0.12.10", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = "0.1" diff --git a/lit-billing-core/rust-toolchain.toml b/lit-billing-core/rust-toolchain.toml new file mode 100644 index 00000000..657737a9 --- /dev/null +++ b/lit-billing-core/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.91" +components = ['rustfmt', 'rust-src', 'clippy'] diff --git a/lit-billing-core/src/balance.rs b/lit-billing-core/src/balance.rs new file mode 100644 index 00000000..88cc2aea --- /dev/null +++ b/lit-billing-core/src/balance.rs @@ -0,0 +1,26 @@ +//! Stripe customer-balance primitives. +//! +//! Stripe represents customer credit as a *negative* balance on the customer +//! object: `balance = -500` means the customer has $5.00 of credit. Credits +//! are applied by writing balance transactions (a negative amount makes the +//! balance more negative, i.e., more credit); charges by writing positive +//! amounts. + +use anyhow::Result; + +use crate::client::StripeClient; + +/// Fetch the raw `balance` field on the Stripe customer. +/// +/// Returns the balance in cents. Negative = credit available, positive = +/// amount owed. Missing/null fields are treated as 0. +pub async fn fetch(client: &StripeClient, customer_id: &str) -> Result { + let resp = client.get(&format!("customers/{customer_id}"), &[]).await?; + let balance = resp + .body + .get("balance") + .and_then(|b| b.as_i64()) + .unwrap_or(0); + tracing::debug!(customer_id, balance, "stripe::fetch_balance: done"); + Ok(balance) +} diff --git a/lit-billing-core/src/client.rs b/lit-billing-core/src/client.rs new file mode 100644 index 00000000..d52c5fd3 --- /dev/null +++ b/lit-billing-core/src/client.rs @@ -0,0 +1,91 @@ +//! Authenticated HTTP client for the Stripe REST API. + +use std::time::Duration; + +use anyhow::Result; + +use crate::http::{StripeResponse, parse_stripe_response, stripe_base}; + +/// Stripe API client: secret key + reqwest client. +/// +/// No caching, no in-process state beyond credentials. Build once and clone +/// freely — `reqwest::Client` is cheap to clone (Arc internally). +#[derive(Clone)] +pub struct StripeClient { + // NOTE: Debug is implemented manually below to redact secret_key. + secret_key: String, + client: reqwest::Client, +} + +impl std::fmt::Debug for StripeClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StripeClient") + .field("secret_key", &"[REDACTED]") + .finish() + } +} + +impl StripeClient { + /// Build a new client with the given Stripe secret key. + pub fn new(secret_key: String) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| anyhow::anyhow!("stripe: failed to build HTTP client: {e}"))?; + Ok(Self { secret_key, client }) + } + + /// `GET /v1/` with optional query params. + pub async fn get(&self, path: &str, query: &[(&str, &str)]) -> Result { + let url = format!("{}/{}", stripe_base(), path); + let resp = self + .client + .get(&url) + .basic_auth(&self.secret_key, Some("")) + .query(query) + .send() + .await?; + let status = resp.status(); + let body_text = resp.text().await?; + parse_stripe_response(status, &body_text) + } + + /// `POST /v1/` with form-encoded body. + pub async fn post(&self, path: &str, params: &[(&str, &str)]) -> Result { + let url = format!("{}/{}", stripe_base(), path); + let resp = self + .client + .post(&url) + .basic_auth(&self.secret_key, Some("")) + .form(params) + .send() + .await?; + let status = resp.status(); + let body_text = resp.text().await?; + parse_stripe_response(status, &body_text) + } + + /// `POST /v1/` with form-encoded body and an `Idempotency-Key` header. + /// + /// Stripe deduplicates requests sharing the same key within 24 hours, making + /// retries after network errors safe from producing duplicate side-effects. + pub async fn post_with_idempotency( + &self, + path: &str, + params: &[(&str, &str)], + idempotency_key: &str, + ) -> Result { + let url = format!("{}/{}", stripe_base(), path); + let resp = self + .client + .post(&url) + .basic_auth(&self.secret_key, Some("")) + .header("Idempotency-Key", idempotency_key) + .form(params) + .send() + .await?; + let status = resp.status(); + let body_text = resp.text().await?; + parse_stripe_response(status, &body_text) + } +} diff --git a/lit-billing-core/src/customer.rs b/lit-billing-core/src/customer.rs new file mode 100644 index 00000000..302dc988 --- /dev/null +++ b/lit-billing-core/src/customer.rs @@ -0,0 +1,59 @@ +//! Customer-identity primitives. +//! +//! The customer-identity invariant: every Lit Stripe customer is keyed by +//! `metadata.wallet_address`. Both billing services depend on this — keep +//! this module the single source of truth. + +use anyhow::Result; + +use crate::client::StripeClient; + +/// Find the Stripe customer for this wallet, creating one if none exists. +/// +/// Concurrency note: Stripe's Search API has indexing lag of several seconds +/// after a customer is created. Callers handling concurrent traffic for the +/// same wallet should layer their own request-coalescing cache on top of this +/// (see `lit-api-server`'s `StripeState::customer_cache`). This function +/// itself does no caching. +pub async fn find_or_create_by_wallet( + client: &StripeClient, + wallet_address: &str, +) -> Result { + // Search by metadata. + let query = format!("metadata['wallet_address']:'{wallet_address}'"); + let resp = client + .get( + "customers/search", + &[("query", query.as_str()), ("limit", "1")], + ) + .await?; + + if let Some(data) = resp.body.get("data").and_then(|d| d.as_array()) + && let Some(first) = data.first() + && let Some(id) = first.get("id").and_then(|i| i.as_str()) + { + return Ok(id.to_string()); + }; + + // Not found, create a new customer + let resp = client + .post("customers", &[("metadata[wallet_address]", wallet_address)]) + .await?; + let id = resp + .body + .get("id") + .and_then(|i| i.as_str()) + .ok_or_else(|| anyhow::anyhow!("Stripe: missing customer id"))?; + Ok(id.to_string()) +} + +/// Set (or update) the email on an existing Stripe customer. +pub async fn set_email(client: &StripeClient, customer_id: &str, email: &str) -> Result<()> { + client + .post( + &format!("customers/{customer_id}"), + &[("email", email.trim())], + ) + .await?; + Ok(()) +} diff --git a/lit-billing-core/src/format.rs b/lit-billing-core/src/format.rs new file mode 100644 index 00000000..f5a79f6d --- /dev/null +++ b/lit-billing-core/src/format.rs @@ -0,0 +1,82 @@ +//! Pure formatting helpers used across billing services. + +/// Format cents as a display string, e.g. 500 → "$5.00". +/// +/// NOTE: known sign-loss bug for values in -99..=-1 (integer division truncates +/// toward zero, so -1/100 = 0, losing the minus sign). Also, the format "$-5.00" +/// is non-standard (convention is "-$5.00"). Preserved as-is for behavior +/// compatibility with the existing `lit-api-server` callers. +pub fn cents_to_display(cents: i64) -> String { + format!("${}.{:02}", cents / 100, cents.abs() % 100) +} + +/// Convert a Unix timestamp (seconds, UTC) to a `YYYY-MM-DD` date string. +/// +/// Pure function — no external deps (avoids pulling `chrono` into billing +/// services). +pub fn unix_to_utc_date(ts: i64) -> String { + // Days since 1970-01-01 (Thursday), floor. + let days = ts.div_euclid(86_400); + // Convert to (year, month, day) using Howard Hinnant's civil_from_days algorithm. + // https://howardhinnant.github.io/date_algorithms.html#civil_from_days + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] + let year = if m <= 2 { y + 1 } else { y }; + format!("{year:04}-{m:02}-{d:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cents_to_display_whole_dollars() { + assert_eq!(cents_to_display(500), "$5.00"); + assert_eq!(cents_to_display(100), "$1.00"); + assert_eq!(cents_to_display(0), "$0.00"); + } + + #[test] + fn cents_to_display_with_cents() { + assert_eq!(cents_to_display(199), "$1.99"); + assert_eq!(cents_to_display(1), "$0.01"); + assert_eq!(cents_to_display(50), "$0.50"); + } + + #[test] + fn cents_to_display_negative() { + // Documenting current behavior — see fn-level NOTE. + assert_eq!(cents_to_display(-500), "$-5.00"); + assert_eq!(cents_to_display(-1), "$0.01"); + } + + #[test] + fn unix_to_utc_date_epoch() { + assert_eq!(unix_to_utc_date(0), "1970-01-01"); + } + + #[test] + fn unix_to_utc_date_known_values() { + // 2026-04-21 00:00:00 UTC = 1_776_729_600 + assert_eq!(unix_to_utc_date(1_776_729_600), "2026-04-21"); + // 2026-04-21 23:59:59 UTC + assert_eq!(unix_to_utc_date(1_776_729_600 + 86_399), "2026-04-21"); + // 2026-04-22 00:00:00 UTC + assert_eq!(unix_to_utc_date(1_776_729_600 + 86_400), "2026-04-22"); + } + + #[test] + fn unix_to_utc_date_leap_year() { + // 2024-02-29 is a leap day. 1709164800 = 2024-02-29 00:00:00 UTC + assert_eq!(unix_to_utc_date(1_709_164_800), "2024-02-29"); + assert_eq!(unix_to_utc_date(1_709_251_199), "2024-02-29"); + assert_eq!(unix_to_utc_date(1_709_251_200), "2024-03-01"); + } +} diff --git a/lit-billing-core/src/http.rs b/lit-billing-core/src/http.rs new file mode 100644 index 00000000..f199f93d --- /dev/null +++ b/lit-billing-core/src/http.rs @@ -0,0 +1,98 @@ +//! Pure HTTP response parsing for the Stripe API. + +use anyhow::Result; +use reqwest::StatusCode; + +/// Parsed Stripe API response preserving the HTTP status code. +#[derive(Debug)] +pub struct StripeResponse { + pub status: StatusCode, + pub body: serde_json::Value, +} + +/// Parse a Stripe API response from raw status + body text. +/// +/// Accepts `(StatusCode, &str)` rather than `reqwest::Response` so this logic +/// is trivially unit-testable without mocking HTTP. +pub fn parse_stripe_response(status: StatusCode, body_text: &str) -> Result { + let body: serde_json::Value = serde_json::from_str(body_text) + .map_err(|e| anyhow::anyhow!("Stripe: invalid JSON (HTTP {status}): {e}"))?; + + if let Some(e) = body.get("error") { + let msg = e + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("Stripe error (HTTP {status}): {msg}"); + } + + Ok(StripeResponse { status, body }) +} + +pub(crate) fn stripe_base() -> &'static str { + "https://api.stripe.com/v1" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_stripe_response_2xx_success() { + let body = r#"{"id": "cus_123", "object": "customer"}"#; + let resp = parse_stripe_response(StatusCode::OK, body).unwrap(); + assert_eq!(resp.status, StatusCode::OK); + assert_eq!(resp.body["id"], "cus_123"); + } + + #[test] + fn parse_stripe_response_4xx_with_error() { + let body = + r#"{"error": {"message": "Invalid API Key provided", "type": "authentication_error"}}"#; + let err = parse_stripe_response(StatusCode::UNAUTHORIZED, body).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("HTTP 401"), "expected HTTP 401 in: {msg}"); + assert!( + msg.contains("Invalid API Key provided"), + "expected error message in: {msg}" + ); + } + + #[test] + fn parse_stripe_response_5xx_with_error() { + let body = r#"{"error": {"message": "Internal server error", "type": "api_error"}}"#; + let err = parse_stripe_response(StatusCode::INTERNAL_SERVER_ERROR, body).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("HTTP 500"), "expected HTTP 500 in: {msg}"); + } + + #[test] + fn parse_stripe_response_error_without_message() { + let body = r#"{"error": {"type": "api_error"}}"#; + let err = parse_stripe_response(StatusCode::BAD_REQUEST, body).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("unknown error"), + "expected 'unknown error' in: {msg}" + ); + } + + #[test] + fn parse_stripe_response_non_json() { + let body = "Bad Gateway"; + let err = parse_stripe_response(StatusCode::BAD_GATEWAY, body).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("invalid JSON"), + "expected 'invalid JSON' in: {msg}" + ); + assert!(msg.contains("HTTP 502"), "expected HTTP 502 in: {msg}"); + } + + #[test] + fn parse_stripe_response_2xx_with_no_error_field() { + let body = r#"{"balance": -500, "currency": "usd"}"#; + let resp = parse_stripe_response(StatusCode::OK, body).unwrap(); + assert_eq!(resp.body["balance"], -500); + } +} diff --git a/lit-billing-core/src/lib.rs b/lit-billing-core/src/lib.rs new file mode 100644 index 00000000..681e9ef3 --- /dev/null +++ b/lit-billing-core/src/lib.rs @@ -0,0 +1,28 @@ +//! Shared Stripe primitives for Lit billing services. +//! +//! This crate owns the customer-identity invariant: every Stripe customer is +//! keyed by `metadata.wallet_address`. Both `lit-api-server` (TEE) and +//! `lit-payments` (non-TEE) depend on this crate so the two services cannot +//! drift on how customers are identified or how balances are read. +//! +//! Scope: +//! - [`StripeClient`] — credentials + HTTP plumbing, no caching. +//! - [`customer`] — wallet ↔ Stripe customer lookup, email updates. +//! - [`balance`] — credit-balance reads. +//! - [`reporting`] — pagination helpers + per-day aggregation for the +//! `stripe_report` binary. +//! - [`format`] — pure helpers (`cents_to_display`, `unix_to_utc_date`). +//! +//! Caching, charge flows, and PaymentIntent flows are intentionally NOT here — +//! they live in `lit-api-server` because they depend on its in-process caches +//! and on-chain wallet resolution. + +pub mod balance; +pub mod client; +pub mod customer; +pub mod format; +pub mod http; +pub mod reporting; + +pub use client::StripeClient; +pub use http::StripeResponse; diff --git a/lit-billing-core/src/reporting.rs b/lit-billing-core/src/reporting.rs new file mode 100644 index 00000000..00a73fa5 --- /dev/null +++ b/lit-billing-core/src/reporting.rs @@ -0,0 +1,307 @@ +//! Stripe customer / balance-transaction listing + aggregation. +//! +//! Powers the `stripe_report` binary. Not used by the running API server. + +use std::collections::BTreeMap; + +use anyhow::Result; + +use crate::client::StripeClient; +use crate::format::unix_to_utc_date; + +/// One Stripe customer as returned by [`list_all_customers`]. +#[derive(Debug, Clone)] +pub struct ReportCustomer { + pub id: String, + pub wallet_address: Option, + pub email: Option, +} + +/// One customer balance transaction as returned by [`list_balance_transactions_since`]. +/// +/// `created` is a Unix timestamp in seconds. `amount` is in the currency's +/// minor unit (cents for USD): positive = charge (debit to the customer's +/// credit balance), negative = credit (top-up). +#[derive(Debug, Clone)] +pub struct ReportBalanceTx { + pub id: String, + pub customer_id: String, + pub amount: i64, + pub created: i64, + pub description: String, +} + +/// One row of the per-day-per-customer usage report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportRow { + pub date: String, + pub customer_id: String, + pub wallet_address: Option, + pub email: Option, + /// Number of positive-amount balance transactions (charges) on this day. + pub charges_count: u64, + /// Sum of positive amounts in cents (debits to the customer's credit balance). + pub charges_cents: i64, + /// Sum of absolute value of negative amounts in cents (credits / top-ups). + pub credits_cents: i64, +} + +/// Page over `GET /v1/customers` and return every customer, 100 at a time. +pub async fn list_all_customers(client: &StripeClient) -> Result> { + let mut out = Vec::new(); + let mut starting_after: Option = None; + loop { + let mut query: Vec<(&str, &str)> = vec![("limit", "100")]; + if let Some(cursor) = starting_after.as_deref() { + query.push(("starting_after", cursor)); + } + let resp = client.get("customers", &query).await?; + let data = resp + .body + .get("data") + .and_then(|d| d.as_array()) + .cloned() + .unwrap_or_default(); + if data.is_empty() { + break; + } + for c in &data { + let Some(id) = c.get("id").and_then(|v| v.as_str()) else { + continue; + }; + let wallet = c + .get("metadata") + .and_then(|m| m.get("wallet_address")) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(|s| s.to_string()); + let email = c + .get("email") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + out.push(ReportCustomer { + id: id.to_string(), + wallet_address: wallet, + email, + }); + } + let has_more = resp + .body + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !has_more { + break; + } + // Cursor must come from the raw response's last item, not from `out`. + // If every item in a page failed the id guard above, `out.last()` would + // not advance and we would re-request the same page forever. + let next_cursor = data + .last() + .and_then(|c| c.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + match next_cursor { + Some(c) => starting_after = Some(c), + None => break, + } + } + Ok(out) +} + +/// Fetch all customer balance transactions created at or after `since_unix` +/// (seconds since epoch), paginating 100 at a time. +pub async fn list_balance_transactions_since( + client: &StripeClient, + customer_id: &str, + since_unix: i64, +) -> Result> { + let path = format!("customers/{customer_id}/balance_transactions"); + let since_str = since_unix.to_string(); + let mut out = Vec::new(); + let mut starting_after: Option = None; + loop { + let mut query: Vec<(&str, &str)> = + vec![("limit", "100"), ("created[gte]", since_str.as_str())]; + if let Some(cursor) = starting_after.as_deref() { + query.push(("starting_after", cursor)); + } + let resp = client.get(&path, &query).await?; + let data = resp + .body + .get("data") + .and_then(|d| d.as_array()) + .cloned() + .unwrap_or_default(); + if data.is_empty() { + break; + } + for tx in &data { + let Some(id) = tx.get("id").and_then(|v| v.as_str()) else { + continue; + }; + // Don't silently default missing amount/created to 0 — that would + // bucket malformed transactions into 1970-01-01 with $0 and skew + // the report. Skip with a warning instead. + let Some(amount) = tx.get("amount").and_then(|v| v.as_i64()) else { + tracing::warn!( + "stripe_report: skipping tx {id} for customer {customer_id}: missing/invalid 'amount' field" + ); + continue; + }; + let Some(created) = tx.get("created").and_then(|v| v.as_i64()) else { + tracing::warn!( + "stripe_report: skipping tx {id} for customer {customer_id}: missing/invalid 'created' field" + ); + continue; + }; + let description = tx + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + out.push(ReportBalanceTx { + id: id.to_string(), + customer_id: customer_id.to_string(), + amount, + created, + description, + }); + } + let has_more = resp + .body + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !has_more { + break; + } + // See note in list_all_customers: derive the cursor from the raw response, + // not from `out`, so a fully-filtered page can't stall the loop. + let next_cursor = data + .last() + .and_then(|c| c.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + match next_cursor { + Some(c) => starting_after = Some(c), + None => break, + } + } + Ok(out) +} + +/// Aggregate a flat list of balance transactions into one row per +/// (date, customer_id) pair. +/// +/// `customers` provides wallet/email lookup by customer id. Transactions whose +/// `customer_id` is not present in `customers` are still bucketed but have +/// `wallet_address` and `email` set to `None`. +pub fn aggregate_report_rows( + customers: &[ReportCustomer], + transactions: &[ReportBalanceTx], +) -> Vec { + let customer_by_id: std::collections::HashMap<&str, &ReportCustomer> = + customers.iter().map(|c| (c.id.as_str(), c)).collect(); + // BTreeMap so output is sorted by (date, customer_id) deterministically. + let mut buckets: BTreeMap<(String, String), ReportRow> = BTreeMap::new(); + for tx in transactions { + let date = unix_to_utc_date(tx.created); + let key = (date.clone(), tx.customer_id.clone()); + let row = buckets.entry(key).or_insert_with(|| { + let cust = customer_by_id.get(tx.customer_id.as_str()).copied(); + ReportRow { + date: date.clone(), + customer_id: tx.customer_id.clone(), + wallet_address: cust.and_then(|c| c.wallet_address.clone()), + email: cust.and_then(|c| c.email.clone()), + charges_count: 0, + charges_cents: 0, + credits_cents: 0, + } + }); + if tx.amount > 0 { + row.charges_count += 1; + row.charges_cents += tx.amount; + } else if tx.amount < 0 { + row.credits_cents += -tx.amount; + } + } + buckets.into_values().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tx(customer_id: &str, amount: i64, created: i64) -> ReportBalanceTx { + ReportBalanceTx { + id: format!("tx_{customer_id}_{created}_{amount}"), + customer_id: customer_id.to_string(), + amount, + created, + description: String::new(), + } + } + + #[test] + fn aggregate_report_rows_empty() { + assert!(aggregate_report_rows(&[], &[]).is_empty()); + } + + #[test] + fn aggregate_report_rows_buckets_by_day_and_customer() { + let customers = vec![ + ReportCustomer { + id: "cus_a".to_string(), + wallet_address: Some("0xA".to_string()), + email: None, + }, + ReportCustomer { + id: "cus_b".to_string(), + wallet_address: Some("0xB".to_string()), + email: Some("b@example.com".to_string()), + }, + ]; + let day1 = 1_776_729_600; // 2026-04-21 00:00:00 UTC + let day2 = day1 + 86_400; // 2026-04-22 00:00:00 UTC + let txs = vec![ + tx("cus_a", 1, day1 + 10), + tx("cus_a", 1, day1 + 20), + tx("cus_a", 1, day2 + 5), + tx("cus_b", 5, day1 + 1), + tx("cus_b", -500, day1 + 2), // top-up credit + ]; + let rows = aggregate_report_rows(&customers, &txs); + assert_eq!(rows.len(), 3); + // Sorted by (date, customer_id) ascending. + assert_eq!(rows[0].date, "2026-04-21"); + assert_eq!(rows[0].customer_id, "cus_a"); + assert_eq!(rows[0].charges_count, 2); + assert_eq!(rows[0].charges_cents, 2); + assert_eq!(rows[0].credits_cents, 0); + assert_eq!(rows[0].wallet_address.as_deref(), Some("0xA")); + assert_eq!(rows[1].date, "2026-04-21"); + assert_eq!(rows[1].customer_id, "cus_b"); + assert_eq!(rows[1].charges_count, 1); + assert_eq!(rows[1].charges_cents, 5); + assert_eq!(rows[1].credits_cents, 500); + assert_eq!(rows[1].email.as_deref(), Some("b@example.com")); + assert_eq!(rows[2].date, "2026-04-22"); + assert_eq!(rows[2].customer_id, "cus_a"); + assert_eq!(rows[2].charges_count, 1); + } + + #[test] + fn aggregate_report_rows_unknown_customer_still_bucketed() { + let day1 = 1_776_729_600; // 2026-04-21 00:00:00 UTC + let txs = vec![tx("cus_unknown", 3, day1)]; + let rows = aggregate_report_rows(&[], &txs); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].customer_id, "cus_unknown"); + assert_eq!(rows[0].wallet_address, None); + assert_eq!(rows[0].email, None); + assert_eq!(rows[0].charges_cents, 3); + } +}