use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{get, post},
};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_a2a::credit::{CreditManager, CreditTerms, PaymentTerms};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::{ErrorBody, HttpError};
use crate::state::AppState;
type CreditStore = Arc<RwLock<HashMap<Uuid, CreditTerms>>>;
pub fn router() -> Router<AppState> {
let store: CreditStore = Arc::new(RwLock::new(HashMap::new()));
Router::new()
.route(
"/a2a/credit",
post({
let s = store.clone();
move |state, headers, body| create_terms(state, headers, body, s)
})
.get({
let s = store.clone();
move |state, headers| list_terms(state, headers, s)
}),
)
.route(
"/a2a/credit/{id}",
get({
let s = store.clone();
move |state, headers, path| get_terms(state, headers, path, s)
}),
)
.route(
"/a2a/credit/{id}/charge",
post({
let s = store.clone();
move |state, headers, path, body| charge_credit(state, headers, path, body, s)
}),
)
.route(
"/a2a/credit/{id}/payment",
post({
let s = store;
move |state, headers, path, body| record_payment(state, headers, path, body, s)
}),
)
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct CreateCreditTermsRequest {
pub creditor_agent_id: String,
pub debtor_agent_id: String,
pub credit_limit: f64,
pub currency: Option<String>,
pub payment_terms: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
pub struct CreditAmountRequest {
pub amount: f64,
pub reference_id: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CreditTermsResponse {
pub id: String,
pub creditor_agent_id: String,
pub debtor_agent_id: String,
pub credit_limit: String,
pub outstanding_balance: String,
pub available_credit: String,
pub currency: String,
pub payment_terms: String,
pub status: String,
}
fn terms_to_response(t: &CreditTerms) -> CreditTermsResponse {
CreditTermsResponse {
id: t.id.to_string(),
creditor_agent_id: t.creditor_agent_id.to_string(),
debtor_agent_id: t.debtor_agent_id.to_string(),
credit_limit: t.credit_limit.to_string(),
outstanding_balance: t.outstanding_balance.to_string(),
available_credit: CreditManager::available_credit(t).to_string(),
currency: t.currency.clone(),
payment_terms: format!("{:?}", t.payment_terms),
status: format!("{:?}", t.status),
}
}
#[utoipa::path(post, path = "/api/v1/a2a/credit", tag = "a2a",
request_body = CreateCreditTermsRequest,
responses((status = 201, body = CreditTermsResponse), (status = 400, body = ErrorBody)))]
#[tracing::instrument(skip_all)]
pub(crate) async fn create_terms(
State(_state): State<AppState>,
_headers: HeaderMap,
Json(req): Json<CreateCreditTermsRequest>,
store: CreditStore,
) -> Result<(StatusCode, Json<CreditTermsResponse>), HttpError> {
let limit = Decimal::try_from(req.credit_limit)
.map_err(|e| HttpError::BadRequest(format!("Invalid credit_limit: {e}")))?;
let payment_terms = match req.payment_terms.as_deref() {
Some("net_15") => PaymentTerms::Net15,
Some("net_30") => PaymentTerms::Net30,
Some("net_60") => PaymentTerms::Net60,
Some("net_90") => PaymentTerms::Net90,
Some("prepaid") => PaymentTerms::Prepaid,
Some(other) => {
return Err(HttpError::BadRequest(format!(
"Invalid payment_terms: {other}. Valid values: net_15, net_30, net_60, net_90, prepaid"
)));
}
None => PaymentTerms::Net30,
};
let terms = CreditManager::create_terms(
req.creditor_agent_id,
req.debtor_agent_id,
limit,
req.currency.as_deref().unwrap_or("USD"),
payment_terms,
"standard",
);
let resp = terms_to_response(&terms);
let mut guard = store.write().map_err(|_| HttpError::InternalError("Lock poisoned".into()))?;
guard.insert(terms.id, terms);
Ok((StatusCode::CREATED, Json(resp)))
}
#[utoipa::path(get, path = "/api/v1/a2a/credit", tag = "a2a",
responses((status = 200, body = Vec<CreditTermsResponse>)))]
#[tracing::instrument(skip_all)]
pub(crate) async fn list_terms(
State(_state): State<AppState>,
_headers: HeaderMap,
store: CreditStore,
) -> Result<Json<Vec<CreditTermsResponse>>, HttpError> {
let guard = store.read().map_err(|_| HttpError::InternalError("Lock poisoned".into()))?;
let terms: Vec<CreditTermsResponse> = guard.values().map(terms_to_response).collect();
Ok(Json(terms))
}
#[utoipa::path(get, path = "/api/v1/a2a/credit/{id}", tag = "a2a",
params(("id" = String, Path, description = "Credit terms ID (UUID)")),
responses((status = 200, body = CreditTermsResponse), (status = 404, body = ErrorBody)))]
#[tracing::instrument(skip_all)]
pub(crate) async fn get_terms(
State(_state): State<AppState>,
_headers: HeaderMap,
Path(id): Path<Uuid>,
store: CreditStore,
) -> Result<Json<CreditTermsResponse>, HttpError> {
let guard = store.read().map_err(|_| HttpError::InternalError("Lock poisoned".into()))?;
let terms = guard
.get(&id)
.ok_or_else(|| HttpError::NotFound(format!("Credit terms {id} not found")))?;
Ok(Json(terms_to_response(terms)))
}
#[utoipa::path(post, path = "/api/v1/a2a/credit/{id}/charge", tag = "a2a",
params(("id" = String, Path, description = "Credit terms ID (UUID)")),
request_body = CreditAmountRequest,
responses((status = 200, body = CreditTermsResponse), (status = 400, body = ErrorBody),
(status = 404, body = ErrorBody)))]
#[tracing::instrument(skip_all)]
pub(crate) async fn charge_credit(
State(_state): State<AppState>,
_headers: HeaderMap,
Path(id): Path<Uuid>,
Json(req): Json<CreditAmountRequest>,
store: CreditStore,
) -> Result<Json<CreditTermsResponse>, HttpError> {
let amount = Decimal::try_from(req.amount)
.map_err(|e| HttpError::BadRequest(format!("Invalid amount: {e}")))?;
let mut guard = store.write().map_err(|_| HttpError::InternalError("Lock poisoned".into()))?;
let terms = guard
.get_mut(&id)
.ok_or_else(|| HttpError::NotFound(format!("Credit terms {id} not found")))?;
CreditManager::charge(terms, amount, req.reference_id)
.map_err(|e| HttpError::BadRequest(format!("Charge failed: {e}")))?;
Ok(Json(terms_to_response(terms)))
}
#[utoipa::path(post, path = "/api/v1/a2a/credit/{id}/payment", tag = "a2a",
params(("id" = String, Path, description = "Credit terms ID (UUID)")),
request_body = CreditAmountRequest,
responses((status = 200, body = CreditTermsResponse), (status = 400, body = ErrorBody),
(status = 404, body = ErrorBody)))]
#[tracing::instrument(skip_all)]
pub(crate) async fn record_payment(
State(_state): State<AppState>,
_headers: HeaderMap,
Path(id): Path<Uuid>,
Json(req): Json<CreditAmountRequest>,
store: CreditStore,
) -> Result<Json<CreditTermsResponse>, HttpError> {
let amount = Decimal::try_from(req.amount)
.map_err(|e| HttpError::BadRequest(format!("Invalid amount: {e}")))?;
let mut guard = store.write().map_err(|_| HttpError::InternalError("Lock poisoned".into()))?;
let terms = guard
.get_mut(&id)
.ok_or_else(|| HttpError::NotFound(format!("Credit terms {id} not found")))?;
CreditManager::record_payment(terms, amount, req.reference_id)
.map_err(|e| HttpError::BadRequest(format!("Payment failed: {e}")))?;
Ok(Json(terms_to_response(terms)))
}
#[cfg(test)]
mod tests {}