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,133 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::models::{Invoice, InvoiceKind, OrderStatus};
|
||||
use crate::modules::order::repo as order_repo;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct InvoiceView {
|
||||
#[serde(flatten)]
|
||||
pub invoice: Invoice,
|
||||
pub order_no: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InvoiceBody {
|
||||
pub title: String,
|
||||
pub tax_no: Option<String>,
|
||||
pub kind: InvoiceKind,
|
||||
}
|
||||
|
||||
const INVOICE_COLS: &str = "id, invoice_no, order_id, user_id, title, tax_no, kind,
|
||||
amount_minor, currency, status, issued_at, created_at";
|
||||
const INVOICE_I: &str = "i.id, i.invoice_no, i.order_id, i.user_id, i.title, i.tax_no, i.kind,
|
||||
i.amount_minor, i.currency, i.status, i.issued_at, i.created_at";
|
||||
|
||||
async fn views(db: &PgPool, invoices: Vec<Invoice>) -> ApiResult<Vec<InvoiceView>> {
|
||||
let order_ids: Vec<Uuid> = invoices.iter().map(|i| i.order_id).collect();
|
||||
let order_nos: Vec<(Uuid, String)> = if order_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as("SELECT id, order_no FROM orders WHERE id = ANY($1)")
|
||||
.bind(&order_ids)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
};
|
||||
let nos: HashMap<Uuid, String> = order_nos.into_iter().collect();
|
||||
Ok(invoices
|
||||
.into_iter()
|
||||
.map(|i| InvoiceView {
|
||||
order_no: nos.get(&i.order_id).cloned().unwrap_or_default(),
|
||||
invoice: i,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_for_user(state: &AppState, user_id: Uuid) -> ApiResult<Vec<InvoiceView>> {
|
||||
let invoices = sqlx::query_as::<_, Invoice>(&format!(
|
||||
"SELECT {INVOICE_COLS} FROM invoices WHERE user_id = $1 ORDER BY created_at DESC"
|
||||
))
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
views(&state.db, invoices).await
|
||||
}
|
||||
|
||||
pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<InvoiceView>> {
|
||||
let invoices = sqlx::query_as::<_, Invoice>(&format!(
|
||||
"SELECT {INVOICE_I} FROM invoices i
|
||||
JOIN orders o ON o.id = i.order_id
|
||||
WHERE o.shop_id = $1
|
||||
ORDER BY i.created_at DESC"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
views(&state.db, invoices).await
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
order_id: Uuid,
|
||||
body: InvoiceBody,
|
||||
) -> ApiResult<InvoiceView> {
|
||||
let order = order_repo::get_for_user(&state.db, user_id, order_id).await?;
|
||||
if matches!(
|
||||
order.status,
|
||||
OrderStatus::PendingPayment | OrderStatus::Cancelled
|
||||
) {
|
||||
return Err(ApiError::BadRequest(
|
||||
"invoices can only be requested for paid orders".into(),
|
||||
));
|
||||
}
|
||||
if body.title.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("title is required".into()));
|
||||
}
|
||||
if body.kind == InvoiceKind::Company
|
||||
&& body.tax_no.as_ref().map(|t| t.trim().is_empty()).unwrap_or(true)
|
||||
{
|
||||
return Err(ApiError::BadRequest(
|
||||
"tax_no is required for company invoices".into(),
|
||||
));
|
||||
}
|
||||
let invoice = sqlx::query_as::<_, Invoice>(&format!(
|
||||
"INSERT INTO invoices (order_id, user_id, title, tax_no, kind, amount_minor, currency)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {INVOICE_COLS}"
|
||||
))
|
||||
.bind(order.id)
|
||||
.bind(user_id)
|
||||
.bind(body.title.trim())
|
||||
.bind(body.tax_no.as_deref().map(str::trim))
|
||||
.bind(body.kind)
|
||||
.bind(order.total_minor)
|
||||
.bind(&order.currency)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "order already has an open invoice"))?;
|
||||
let mut out = views(&state.db, vec![invoice]).await?;
|
||||
Ok(out.remove(0))
|
||||
}
|
||||
|
||||
pub async fn issue(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<InvoiceView> {
|
||||
let invoice = sqlx::query_as::<_, Invoice>(&format!(
|
||||
"UPDATE invoices i
|
||||
SET status = 'issued', issued_at = now(),
|
||||
invoice_no = 'INV' || to_char(now(), 'YYMMDD') || lpad(nextval('invoice_no_seq')::text, 6, '0')
|
||||
FROM orders o
|
||||
WHERE i.id = $1 AND o.id = i.order_id AND o.shop_id = $2 AND i.status = 'requested'
|
||||
RETURNING {INVOICE_I}"
|
||||
))
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Conflict("invoice not found or not in requested status".into()))?;
|
||||
let mut out = views(&state.db, vec![invoice]).await?;
|
||||
Ok(out.remove(0))
|
||||
}
|
||||
Reference in New Issue
Block a user