#[cfg(feature = "acp")]
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
use crate::acp::models::*;
type SharedState = Arc<RwLock<AppState>>;
#[derive(Default)]
pub struct AppState {
sessions: HashMap<String, CheckoutSession>,
idempotency_cache: HashMap<String, CheckoutSession>,
}
#[cfg(feature = "acp")]
pub async fn create_checkout(
State(state): State<SharedState>,
headers: HeaderMap,
Json(req): Json<CheckoutSessionCreateRequest>,
) -> Result<(StatusCode, Json<CheckoutSession>), AcpError> {
let idempotency_key = headers
.get("Idempotency-Key")
.and_then(|v| v.to_str().ok())
.map(String::from);
if let Some(key) = &idempotency_key {
let state_read = state.read().unwrap();
if let Some(cached) = state_read.idempotency_cache.get(key) {
return Ok((StatusCode::OK, Json(cached.clone())));
}
}
if req.items.is_empty() {
return Err(AcpError::InvalidRequest {
code: "items_required".to_string(),
message: "At least one item is required".to_string(),
param: Some("items".to_string()),
});
}
let mut session = CheckoutSession::new(req.items, "USD".to_string());
session.buyer = req.buyer;
session.fulfillment_address = req.fulfillment_address;
let total_amount: i64 = session.line_items.iter().map(|li| li.total).sum();
session.totals.push(Total {
r#type: TotalType::Total,
display_text: "Total".to_string(),
amount: total_amount,
});
if session.buyer.is_some() && session.fulfillment_address.is_some() {
session.status = CheckoutStatus::ReadyForPayment;
}
let id = session.id.clone();
let mut state_write = state.write().unwrap();
state_write.sessions.insert(id.clone(), session.clone());
if let Some(key) = idempotency_key {
state_write.idempotency_cache.insert(key, session.clone());
}
Ok((StatusCode::CREATED, Json(session)))
}
#[cfg(feature = "acp")]
pub async fn get_checkout(
State(state): State<SharedState>,
Path(id): Path<String>,
) -> Result<Json<CheckoutSession>, AcpError> {
let state_read = state.read().unwrap();
state_read
.sessions
.get(&id)
.cloned()
.map(Json)
.ok_or(AcpError::SessionNotFound(id))
}
#[cfg(feature = "acp")]
pub async fn update_checkout(
State(state): State<SharedState>,
Path(id): Path<String>,
Json(req): Json<CheckoutSessionUpdateRequest>,
) -> Result<Json<CheckoutSession>, AcpError> {
let mut state_write = state.write().unwrap();
let session = state_write
.sessions
.get_mut(&id)
.ok_or(AcpError::SessionNotFound(id.clone()))?;
if matches!(session.status, CheckoutStatus::Completed | CheckoutStatus::Canceled) {
return Err(AcpError::SessionCompleted);
}
if let Some(buyer) = req.buyer {
session.buyer = Some(buyer);
}
if let Some(items) = req.items {
session.line_items = items
.into_iter()
.map(|item| {
let base_amount = 0;
LineItem {
id: format!("li_{}", Uuid::new_v4()),
item,
base_amount,
discount: 0,
subtotal: base_amount,
tax: 0,
total: base_amount,
}
})
.collect();
}
if let Some(address) = req.fulfillment_address {
session.fulfillment_address = Some(address);
}
if let Some(option_id) = req.fulfillment_option_id {
session.fulfillment_option_id = Some(option_id);
}
let total_amount: i64 = session.line_items.iter().map(|li| li.total).sum();
session.totals = vec![Total {
r#type: TotalType::Total,
display_text: "Total".to_string(),
amount: total_amount,
}];
if session.buyer.is_some() && session.fulfillment_address.is_some() {
session.status = CheckoutStatus::ReadyForPayment;
}
Ok(Json(session.clone()))
}
#[cfg(feature = "acp")]
pub async fn complete_checkout(
State(state): State<SharedState>,
Path(id): Path<String>,
Json(req): Json<CheckoutSessionCompleteRequest>,
) -> Result<Json<CheckoutSessionWithOrder>, AcpError> {
let mut state_write = state.write().unwrap();
let session = state_write
.sessions
.get_mut(&id)
.ok_or(AcpError::SessionNotFound(id.clone()))?;
match session.status {
CheckoutStatus::Completed => return Err(AcpError::SessionCompleted),
CheckoutStatus::Canceled => return Err(AcpError::SessionCanceled),
CheckoutStatus::NotReadyForPayment => return Err(AcpError::NotReadyForPayment),
_ => {}
}
if let Some(buyer) = req.buyer {
session.buyer = Some(buyer);
}
if req.payment_data.token.is_empty() {
return Err(AcpError::PaymentDeclined("Invalid payment token".to_string()));
}
session.status = CheckoutStatus::Completed;
let order = Order {
id: format!("ord_{}", Uuid::new_v4()),
checkout_session_id: session.id.clone(),
permalink_url: format!("https://merchant.example.com/orders/{}", session.id),
};
let result = CheckoutSessionWithOrder {
session: session.clone(),
order,
};
Ok(Json(result))
}
#[cfg(feature = "acp")]
pub async fn cancel_checkout(
State(state): State<SharedState>,
Path(id): Path<String>,
) -> Result<Json<CheckoutSession>, AcpError> {
let mut state_write = state.write().unwrap();
let session = state_write
.sessions
.get_mut(&id)
.ok_or(AcpError::SessionNotFound(id))?;
if matches!(session.status, CheckoutStatus::Completed | CheckoutStatus::Canceled) {
return Err(AcpError::CannotCancel);
}
session.status = CheckoutStatus::Canceled;
Ok(Json(session.clone()))
}
#[cfg(feature = "acp")]
pub async fn delegate_payment(
State(_state): State<SharedState>,
Json(req): Json<DelegatePaymentRequest>,
) -> Result<(StatusCode, Json<DelegatePaymentResponse>), AcpError> {
if req.checkout_session_id.is_empty() {
return Err(AcpError::InvalidRequest {
code: "session_required".to_string(),
message: "checkout_session_id is required".to_string(),
param: Some("checkout_session_id".to_string()),
});
}
let token_id = format!("spt_{}", Uuid::new_v4());
let created = chrono::Utc::now().to_rfc3339();
let response = DelegatePaymentResponse {
id: token_id,
created,
metadata: req.metadata,
};
Ok((StatusCode::CREATED, Json(response)))
}
#[derive(Debug)]
pub enum AcpError {
InvalidRequest {
code: String,
message: String,
param: Option<String>,
},
SessionNotFound(String),
SessionCompleted,
SessionCanceled,
NotReadyForPayment,
PaymentDeclined(String),
CannotCancel,
}
#[cfg(feature = "acp")]
impl IntoResponse for AcpError {
fn into_response(self) -> Response {
let (status, error_response) = match self {
AcpError::InvalidRequest { code, message, param } => (
StatusCode::BAD_REQUEST,
ErrorResponse {
r#type: "invalid_request".to_string(),
code,
message,
param,
},
),
AcpError::SessionNotFound(id) => (
StatusCode::NOT_FOUND,
ErrorResponse {
r#type: "invalid_request".to_string(),
code: "session_not_found".to_string(),
message: format!("Checkout session not found: {}", id),
param: Some("checkout_session_id".to_string()),
},
),
AcpError::SessionCompleted => (
StatusCode::METHOD_NOT_ALLOWED,
ErrorResponse {
r#type: "invalid_request".to_string(),
code: "session_completed".to_string(),
message: "Session is already completed".to_string(),
param: None,
},
),
AcpError::SessionCanceled => (
StatusCode::METHOD_NOT_ALLOWED,
ErrorResponse {
r#type: "invalid_request".to_string(),
code: "session_canceled".to_string(),
message: "Session is canceled".to_string(),
param: None,
},
),
AcpError::NotReadyForPayment => (
StatusCode::BAD_REQUEST,
ErrorResponse {
r#type: "invalid_request".to_string(),
code: "not_ready_for_payment".to_string(),
message: "Session is not ready for payment".to_string(),
param: None,
},
),
AcpError::PaymentDeclined(reason) => (
StatusCode::PAYMENT_REQUIRED,
ErrorResponse {
r#type: "processing_error".to_string(),
code: "payment_declined".to_string(),
message: reason,
param: None,
},
),
AcpError::CannotCancel => (
StatusCode::METHOD_NOT_ALLOWED,
ErrorResponse {
r#type: "invalid_request".to_string(),
code: "cannot_cancel".to_string(),
message: "Session cannot be canceled".to_string(),
param: None,
},
),
};
(status, Json(error_response)).into_response()
}
}