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, 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) -> ApiResult> { let order_ids: Vec = 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 = 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> { 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> { 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 { 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 { 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)) }