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>
134 lines
4.4 KiB
Rust
134 lines
4.4 KiB
Rust
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))
|
|
}
|