refactor(api): split Axum handlers into handler/service/repo modules
Keep the REST contract; move domain logic out of route files so checkout, fulfillment, and identity can be reused across customer, shop, and admin surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e457339847
commit
5e866d08f4
@@ -0,0 +1,99 @@
|
||||
use crate::error::ApiResult;
|
||||
use crate::models::Currency;
|
||||
use crate::money::convert_minor;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn load_currencies(state: &AppState, enabled_only: bool) -> ApiResult<Vec<Currency>> {
|
||||
let rows = if enabled_only {
|
||||
sqlx::query_as::<_, Currency>(
|
||||
"SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled
|
||||
FROM currencies WHERE enabled = TRUE ORDER BY code",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Currency>(
|
||||
"SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled
|
||||
FROM currencies ORDER BY code",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn convert(
|
||||
state: &AppState,
|
||||
amount_minor: i64,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> ApiResult<(i64, String)> {
|
||||
let currencies = load_currencies(state, true).await?;
|
||||
let from_c = currencies
|
||||
.iter()
|
||||
.find(|c| c.code == from.to_uppercase())
|
||||
.ok_or_else(|| {
|
||||
crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {from}"))
|
||||
})?;
|
||||
let to_c = currencies
|
||||
.iter()
|
||||
.find(|c| c.code == to.to_uppercase())
|
||||
.ok_or_else(|| {
|
||||
crate::error::ApiError::BadRequest(format!("unknown or disabled currency: {to}"))
|
||||
})?;
|
||||
let converted = convert_minor(amount_minor, from_c, to_c)?;
|
||||
Ok((converted, to_c.code.clone()))
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
state: &AppState,
|
||||
code: String,
|
||||
name: serde_json::Value,
|
||||
symbol: String,
|
||||
exponent: i16,
|
||||
rate_to_base: rust_decimal::Decimal,
|
||||
enabled: bool,
|
||||
) -> ApiResult<Currency> {
|
||||
use crate::error::ApiError;
|
||||
let code = code.to_uppercase();
|
||||
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Err(ApiError::BadRequest("code must be a 3-letter ISO code".into()));
|
||||
}
|
||||
if rate_to_base <= rust_decimal::Decimal::ZERO {
|
||||
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
|
||||
}
|
||||
if !(0..=6).contains(&exponent) {
|
||||
return Err(ApiError::BadRequest("exponent must be 0..=6".into()));
|
||||
}
|
||||
Ok(sqlx::query_as::<_, Currency>(
|
||||
"INSERT INTO currencies (code, name, symbol, exponent, rate_to_base, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET name = $2, symbol = $3, exponent = $4, rate_to_base = $5, enabled = $6
|
||||
RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled",
|
||||
)
|
||||
.bind(&code)
|
||||
.bind(&name)
|
||||
.bind(&symbol)
|
||||
.bind(exponent)
|
||||
.bind(rate_to_base)
|
||||
.bind(enabled)
|
||||
.fetch_one(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn set_rate(state: &AppState, code: &str, rate: rust_decimal::Decimal) -> ApiResult<Currency> {
|
||||
use crate::error::ApiError;
|
||||
if rate <= rust_decimal::Decimal::ZERO {
|
||||
return Err(ApiError::BadRequest("rate_to_base must be > 0".into()));
|
||||
}
|
||||
sqlx::query_as::<_, Currency>(
|
||||
"UPDATE currencies SET rate_to_base = $2 WHERE code = $1
|
||||
RETURNING code, name, symbol, exponent, is_base, rate_to_base, enabled",
|
||||
)
|
||||
.bind(code.to_uppercase())
|
||||
.bind(rate)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("currency".into()))
|
||||
}
|
||||
Reference in New Issue
Block a user