use super::{ build_admin_billing_bad_request_response as bad_request, build_admin_billing_conflict_response as conflict, build_admin_billing_data_unavailable_response as unavailable, build_admin_billing_not_found_response as not_found, }; use crate::handlers::admin::{ request::{AdminAppState, AdminRequestContext}, shared::{attach_admin_audit_response, query_param_value}, }; use crate::handlers::shared::normalize_payment_currency; use crate::GatewayError; use aether_data_contracts::repository::billing::*; use axum::{ body::{Body, Bytes}, http::{self, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::Deserialize; use serde_json::{json, Value}; #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ExpenseRequest { client_request_id: String, provider_id: String, kind: String, amount: String, currency: String, paid_at: String, period_start: Option, period_end: Option, note: Option, external_reference: Option, } fn datetime(value: u64) -> String { chrono::DateTime::from_timestamp_millis(value as i64) .expect("valid stored timestamp") .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } fn parse_date(value: &str) -> Result { chrono::DateTime::parse_from_rfc3339(value) .ok() .and_then(|v| u64::try_from(v.timestamp_millis()).ok()) .filter(|v| *v <= 253_402_300_799_000) .ok_or_else(|| "timestamps must be RFC3339 dates on or after 1970".into()) } fn optional_text(value: Option) -> Option { value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()) } fn expense_json(record: &ProviderExpenseRecord) -> Value { let e = &record.entry; json!({ "id": record.id, "client_request_id": e.client_request_id, "provider_id": e.provider_id, "provider_name": e.provider_name, "kind": e.kind, "amount": e.amount, "currency": e.currency, "paid_at": datetime(e.paid_at_unix_ms), "period_start": e.period_start_unix_ms.map(datetime), "period_end": e.period_end_unix_ms.map(datetime), "note": e.note, "external_reference": e.external_reference, "created_by": e.created_by, "created_at": datetime(record.created_at_unix_ms), "status": if record.voided_at_unix_ms.is_some() { "void" } else { "recorded" }, "voided_at": record.voided_at_unix_ms.map(datetime), "voided_by": record.voided_by, }) } fn csv_cell(value: &str) -> String { let value = if value.trim_start().starts_with(['=', '+', '-', '@']) || value.starts_with(['\t', '\r', '\n']) { format!("'{value}") } else { value.to_string() }; format!("\"{}\"", value.replace('"', "\"\"")) } fn csv_report(items: &[ProviderExpenseRecord]) -> String { let mut result=String::from("\u{feff}id,provider_id,provider_name,kind,amount,currency,paid_at,period_start,period_end,note,external_reference,created_by,created_at\r\n"); for r in items { let e = &r.entry; let fields = [ r.id.clone(), e.provider_id.clone(), e.provider_name.clone(), e.kind.clone(), e.amount.clone(), e.currency.clone(), datetime(e.paid_at_unix_ms), e.period_start_unix_ms.map(datetime).unwrap_or_default(), e.period_end_unix_ms.map(datetime).unwrap_or_default(), e.note.clone().unwrap_or_default(), e.external_reference.clone().unwrap_or_default(), e.created_by.clone().unwrap_or_default(), datetime(r.created_at_unix_ms), ]; result.push_str( &fields .iter() .map(|s| csv_cell(s)) .collect::>() .join(","), ); result.push_str("\r\n"); } result } fn query(context: &AdminRequestContext<'_>, csv: bool) -> Result { let q = context.query_string(); let now = chrono::Utc::now().timestamp_millis().max(0) as u64; let from = query_param_value(q, "from") .map(|v| parse_date(&v)) .transpose()? .unwrap_or(now.saturating_sub(30 * 86_400_000)); let to = query_param_value(q, "to") .map(|v| parse_date(&v)) .transpose()? .unwrap_or(now); let limit = if csv { 10_001 } else { query_param_value(q, "limit") .map(|v| v.parse::().map_err(|_| "invalid limit".to_string())) .transpose()? .unwrap_or(25) }; let offset = if csv { 0 } else { query_param_value(q, "offset") .map(|v| v.parse::().map_err(|_| "invalid offset".to_string())) .transpose()? .unwrap_or(0) }; if !csv && limit > 200 { return Err("limit must be at most 200".into()); } let q = ProviderExpenseQuery { from_unix_ms: from, to_unix_ms: to, limit, offset, }; q.validate().map_err(|e| e.to_string())?; Ok(q) } pub(super) async fn response( state: &AdminAppState<'_>, context: &AdminRequestContext<'_>, body: Option<&Bytes>, ) -> Result>, GatewayError> { let path = context.path().trim_end_matches('/'); if context.route_family() != Some("billing_manage") || !path.starts_with("/api/admin/billing/provider-expenses") { return Ok(None); } let operator = context .decision() .and_then(|d| d.admin_principal.as_ref()) .map(|p| p.user_id.clone()); if path == "/api/admin/billing/provider-expenses" && context.method() == http::Method::GET { let csv = query_param_value(context.query_string(), "format").as_deref() == Some("csv"); let q = match query(context, csv) { Ok(v) => v, Err(e) => return Ok(Some(bad_request(e))), }; let Some(page) = state .app() .data .list_provider_expenses(&q) .await .map_err(|e| GatewayError::Internal(e.to_string()))? else { return Ok(Some(unavailable())); }; if csv { if page.total > 10_000 { return Ok(Some( ( StatusCode::UNPROCESSABLE_ENTITY, Json(json!({"detail":"导出超过 10000 条,请缩小时间范围"})), ) .into_response(), )); } return Ok(Some( ( [ (http::header::CONTENT_TYPE, "text/csv; charset=utf-8"), ( http::header::CONTENT_DISPOSITION, "attachment; filename=provider-expenses.csv", ), (http::header::CACHE_CONTROL, "private, no-store"), ], csv_report(&page.items), ) .into_response(), )); } return Ok(Some( ( [(http::header::CACHE_CONTROL, "private, no-store")], Json(json!({ "items": page.items.iter().map(expense_json).collect::>(), "total": page.total, "totals": page.totals, "providers": page.providers, "limit": q.limit, "offset": q.offset, "from": datetime(q.from_unix_ms), "to": datetime(q.to_unix_ms), "time_basis": "paid_at", "source": "manual_ledger", })), ) .into_response(), )); } if path == "/api/admin/billing/provider-expenses" && context.method() == http::Method::POST { let Some(body) = body else { return Ok(Some(bad_request("缺少请求体"))); }; let payload = match serde_json::from_slice::(body) { Ok(v) => v, Err(_) => return Ok(Some(bad_request("输入验证失败"))), }; let input = (|| -> Result { let units = provider_expense_amount_units(&payload.amount) .ok_or("amount must be a positive decimal string with at most 8 decimal places")?; let input = ProviderExpenseInput { client_request_id: uuid::Uuid::parse_str(&payload.client_request_id) .map_err(|_| "client_request_id must be a UUID")? .to_string(), provider_id: payload.provider_id.trim().into(), provider_name: "pending".into(), kind: payload.kind, amount: format_provider_expense_amount(units), currency: normalize_payment_currency(&payload.currency, "currency")?, paid_at_unix_ms: parse_date(&payload.paid_at)?, period_start_unix_ms: payload .period_start .as_deref() .map(parse_date) .transpose()?, period_end_unix_ms: payload.period_end.as_deref().map(parse_date).transpose()?, note: optional_text(payload.note), external_reference: optional_text(payload.external_reference), created_by: operator.clone(), }; input.validate()?; Ok(input) })(); let mut input = match input { Ok(v) => v, Err(e) => return Ok(Some(bad_request(e))), }; let providers = state .read_provider_catalog_providers_by_ids(&[input.provider_id.clone()]) .await?; let Some(provider) = providers.first() else { return Ok(Some(not_found("Provider not found"))); }; input.provider_name = provider.name.clone(); let result = state .app() .data .create_provider_expense(&input) .await .map_err(|e| GatewayError::Internal(e.to_string()))?; return Ok(Some(mutation_response( result, "admin_provider_expense_recorded", "record_provider_expense", ))); } if context.method() == http::Method::POST { if let Some(id) = path .strip_prefix("/api/admin/billing/provider-expenses/") .and_then(|v| v.strip_suffix("/void")) .filter(|v| !v.is_empty() && !v.contains('/')) { if uuid::Uuid::parse_str(id).is_err() { return Ok(Some(bad_request("invalid expense id"))); } let result = state .app() .data .void_provider_expense(id, operator.as_deref()) .await .map_err(|e| GatewayError::Internal(e.to_string()))?; return Ok(Some(mutation_response( result, "admin_provider_expense_voided", "void_provider_expense", ))); } } Ok(None) } fn mutation_response( outcome: AdminBillingMutationOutcome, event: &'static str, action: &'static str, ) -> Response { match outcome { AdminBillingMutationOutcome::Applied(record) => attach_admin_audit_response( Json(json!({"item":expense_json(&record)})).into_response(), event, action, "provider_expense", &record.id, ), AdminBillingMutationOutcome::Invalid(e) => conflict(e), AdminBillingMutationOutcome::NotFound => not_found("Provider expense not found"), AdminBillingMutationOutcome::Unavailable => unavailable(), } } #[cfg(test)] mod tests { use super::*; #[test] fn provider_expense_csv_neutralizes_formulas_and_quotes_fields() { assert_eq!(csv_cell("=cmd()"), "\"'=cmd()\""); assert_eq!(csv_cell(" @cmd"), "\"' @cmd\""); assert_eq!(csv_cell("\tcmd"), "\"'\tcmd\""); assert_eq!(csv_cell("a,\"b\"\nc"), "\"a,\"\"b\"\"\nc\""); assert_eq!(csv_cell("12.34"), "\"12.34\""); } #[test] fn provider_expense_dates_require_explicit_timezone_and_nonnegative_epoch() { assert_eq!( parse_date("2026-09-20T08:00:00+08:00"), parse_date("2026-09-20T00:00:00Z") ); assert!(parse_date("2026-09-20").is_err()); assert!(parse_date("1969-01-01T00:00:00Z").is_err()); } }