use std::str::FromStr; use axum::{ extract::{Path, Query, State}, http::StatusCode, routing::{get, put}, Json, Router, }; use bigdecimal::BigDecimal; use chrono::{DateTime, Datelike, NaiveDate, Utc}; use diesel::prelude::*; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; use crate::bot::get_default_bot; use crate::core::shared::schema::{ billing_invoice_items, billing_invoices, billing_payments, billing_quote_items, billing_quotes, billing_recurring, billing_tax_rates, }; use crate::shared::state::AppState; #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = billing_invoices)] pub struct BillingInvoice { pub id: Uuid, pub org_id: Uuid, pub bot_id: Uuid, pub invoice_number: String, pub customer_id: Option, pub customer_name: String, pub customer_email: Option, pub customer_address: Option, pub status: String, pub issue_date: NaiveDate, pub due_date: NaiveDate, pub subtotal: BigDecimal, pub tax_rate: BigDecimal, pub tax_amount: BigDecimal, pub discount_percent: BigDecimal, pub discount_amount: BigDecimal, pub total: BigDecimal, pub amount_paid: BigDecimal, pub amount_due: BigDecimal, pub currency: String, pub notes: Option, pub terms: Option, pub footer: Option, pub paid_at: Option>, pub sent_at: Option>, pub voided_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = billing_invoice_items)] pub struct BillingInvoiceItem { pub id: Uuid, pub invoice_id: Uuid, pub product_id: Option, pub description: String, pub quantity: BigDecimal, pub unit_price: BigDecimal, pub discount_percent: BigDecimal, pub tax_rate: BigDecimal, pub amount: BigDecimal, pub sort_order: i32, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = billing_payments)] pub struct BillingPayment { pub id: Uuid, pub org_id: Uuid, pub bot_id: Uuid, pub invoice_id: Option, pub payment_number: String, pub amount: BigDecimal, pub currency: String, pub payment_method: String, pub payment_reference: Option, pub status: String, pub payer_name: Option, pub payer_email: Option, pub notes: Option, pub paid_at: DateTime, pub refunded_at: Option>, pub refund_amount: Option, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = billing_quotes)] pub struct BillingQuote { pub id: Uuid, pub org_id: Uuid, pub bot_id: Uuid, pub quote_number: String, pub customer_id: Option, pub customer_name: String, pub customer_email: Option, pub customer_address: Option, pub status: String, pub issue_date: NaiveDate, pub valid_until: NaiveDate, pub subtotal: BigDecimal, pub tax_rate: BigDecimal, pub tax_amount: BigDecimal, pub discount_percent: BigDecimal, pub discount_amount: BigDecimal, pub total: BigDecimal, pub currency: String, pub notes: Option, pub terms: Option, pub accepted_at: Option>, pub rejected_at: Option>, pub converted_invoice_id: Option, pub sent_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = billing_quote_items)] pub struct BillingQuoteItem { pub id: Uuid, pub quote_id: Uuid, pub product_id: Option, pub description: String, pub quantity: BigDecimal, pub unit_price: BigDecimal, pub discount_percent: BigDecimal, pub tax_rate: BigDecimal, pub amount: BigDecimal, pub sort_order: i32, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)] #[diesel(table_name = billing_recurring)] pub struct BillingRecurring { pub id: Uuid, pub org_id: Uuid, pub bot_id: Uuid, pub customer_id: Option, pub customer_name: String, pub customer_email: Option, pub status: String, pub frequency: String, pub interval_count: i32, pub amount: BigDecimal, pub currency: String, pub description: Option, pub next_invoice_date: NaiveDate, pub last_invoice_date: Option, pub last_invoice_id: Option, pub start_date: NaiveDate, pub end_date: Option, pub invoices_generated: i32, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)] #[diesel(table_name = billing_tax_rates)] pub struct BillingTaxRate { pub id: Uuid, pub org_id: Uuid, pub bot_id: Uuid, pub name: String, pub rate: BigDecimal, pub description: Option, pub region: Option, pub is_default: bool, pub is_active: bool, pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateInvoiceRequest { pub customer_name: String, pub customer_email: Option, pub customer_address: Option, pub customer_id: Option, pub issue_date: Option, pub due_date: Option, pub tax_rate: Option, pub discount_percent: Option, pub currency: Option, pub notes: Option, pub terms: Option, pub items: Option>, } #[derive(Debug, Deserialize)] pub struct InvoiceItemRequest { pub description: String, pub quantity: f64, pub unit_price: f64, pub discount_percent: Option, pub tax_rate: Option, pub product_id: Option, } #[derive(Debug, Deserialize)] pub struct UpdateInvoiceRequest { pub customer_name: Option, pub customer_email: Option, pub customer_address: Option, pub due_date: Option, pub tax_rate: Option, pub discount_percent: Option, pub notes: Option, pub terms: Option, } #[derive(Debug, Deserialize)] pub struct RecordPaymentRequest { pub invoice_id: Option, pub amount: f64, pub payment_method: Option, pub payment_reference: Option, pub payer_name: Option, pub payer_email: Option, pub notes: Option, } #[derive(Debug, Deserialize)] pub struct CreateQuoteRequest { pub customer_name: String, pub customer_email: Option, pub customer_address: Option, pub customer_id: Option, pub issue_date: Option, pub valid_until: Option, pub tax_rate: Option, pub discount_percent: Option, pub currency: Option, pub notes: Option, pub terms: Option, pub items: Option>, } #[derive(Debug, Deserialize)] pub struct ListQuery { pub search: Option, pub status: Option, pub customer_id: Option, pub from_date: Option, pub to_date: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Serialize)] pub struct BillingStats { pub total_revenue: f64, pub revenue_this_month: f64, pub pending_amount: f64, pub overdue_amount: f64, pub paid_this_month: f64, pub invoice_count: i64, pub payment_count: i64, pub overdue_count: i64, } #[derive(Debug, Serialize)] pub struct InvoiceWithItems { pub invoice: BillingInvoice, pub items: Vec, } #[derive(Debug, Serialize)] pub struct QuoteWithItems { pub quote: BillingQuote, pub items: Vec, } fn get_bot_context(state: &AppState) -> (Uuid, Uuid) { let Ok(mut conn) = state.conn.get() else { return (Uuid::nil(), Uuid::nil()); }; let (bot_id, _bot_name) = get_default_bot(&mut conn); let org_id = Uuid::nil(); (org_id, bot_id) } fn generate_invoice_number(conn: &mut diesel::PgConnection, org_id: Uuid) -> String { let count: i64 = billing_invoices::table .filter(billing_invoices::org_id.eq(org_id)) .count() .get_result(conn) .unwrap_or(0); format!("INV-{:06}", count + 1) } fn generate_payment_number(conn: &mut diesel::PgConnection, org_id: Uuid) -> String { let count: i64 = billing_payments::table .filter(billing_payments::org_id.eq(org_id)) .count() .get_result(conn) .unwrap_or(0); format!("PAY-{:06}", count + 1) } fn generate_quote_number(conn: &mut diesel::PgConnection, org_id: Uuid) -> String { let count: i64 = billing_quotes::table .filter(billing_quotes::org_id.eq(org_id)) .count() .get_result(conn) .unwrap_or(0); format!("QTE-{:06}", count + 1) } fn bd(val: f64) -> BigDecimal { BigDecimal::from_str(&val.to_string()).unwrap_or_else(|_| BigDecimal::from(0)) } fn bd_to_f64(val: &BigDecimal) -> f64 { val.to_string().parse::().unwrap_or(0.0) } pub async fn create_invoice( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let id = Uuid::new_v4(); let now = Utc::now(); let invoice_number = generate_invoice_number(&mut conn, org_id); let issue_date = req .issue_date .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()) .unwrap_or_else(|| now.date_naive()); let due_date = req .due_date .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()) .unwrap_or_else(|| issue_date + chrono::Duration::days(30)); let tax_rate = bd(req.tax_rate.unwrap_or(0.0)); let discount_percent = bd(req.discount_percent.unwrap_or(0.0)); let mut subtotal = bd(0.0); let items = req.items.unwrap_or_default(); for item in &items { let item_amount = item.quantity * item.unit_price; let item_discount = item_amount * item.discount_percent.unwrap_or(0.0) / 100.0; subtotal = subtotal + bd(item_amount - item_discount); } let discount_amount = &subtotal * &discount_percent / bd(100.0); let taxable = &subtotal - &discount_amount; let tax_amount = &taxable * &tax_rate / bd(100.0); let total = &taxable + &tax_amount; let invoice = BillingInvoice { id, org_id, bot_id, invoice_number, customer_id: req.customer_id, customer_name: req.customer_name, customer_email: req.customer_email, customer_address: req.customer_address, status: "draft".to_string(), issue_date, due_date, subtotal: subtotal.clone(), tax_rate, tax_amount, discount_percent, discount_amount, total: total.clone(), amount_paid: bd(0.0), amount_due: total, currency: req.currency.unwrap_or_else(|| "USD".to_string()), notes: req.notes, terms: req.terms, footer: None, paid_at: None, sent_at: None, voided_at: None, created_at: now, updated_at: now, }; diesel::insert_into(billing_invoices::table) .values(&invoice) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; for (idx, item) in items.iter().enumerate() { let item_amount = item.quantity * item.unit_price; let item_discount = item_amount * item.discount_percent.unwrap_or(0.0) / 100.0; let final_amount = item_amount - item_discount; let inv_item = BillingInvoiceItem { id: Uuid::new_v4(), invoice_id: id, product_id: item.product_id, description: item.description.clone(), quantity: bd(item.quantity), unit_price: bd(item.unit_price), discount_percent: bd(item.discount_percent.unwrap_or(0.0)), tax_rate: bd(item.tax_rate.unwrap_or(0.0)), amount: bd(final_amount), sort_order: idx as i32, created_at: now, }; diesel::insert_into(billing_invoice_items::table) .values(&inv_item) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert item error: {e}")))?; } Ok(Json(invoice)) } pub async fn list_invoices( State(state): State>, Query(query): Query, ) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); let mut q = billing_invoices::table .filter(billing_invoices::org_id.eq(org_id)) .filter(billing_invoices::bot_id.eq(bot_id)) .into_boxed(); if let Some(status) = query.status { if status != "all" { q = q.filter(billing_invoices::status.eq(status)); } } if let Some(customer_id) = query.customer_id { q = q.filter(billing_invoices::customer_id.eq(customer_id)); } if let Some(search) = query.search { let pattern = format!("%{search}%"); q = q.filter( billing_invoices::invoice_number .ilike(pattern.clone()) .or(billing_invoices::customer_name.ilike(pattern)), ); } let invoices: Vec = q .order(billing_invoices::created_at.desc()) .limit(limit) .offset(offset) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(invoices)) } pub async fn get_invoice( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let invoice: BillingInvoice = billing_invoices::table .filter(billing_invoices::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; let items: Vec = billing_invoice_items::table .filter(billing_invoice_items::invoice_id.eq(id)) .order(billing_invoice_items::sort_order.asc()) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(InvoiceWithItems { invoice, items })) } pub async fn update_invoice( State(state): State>, Path(id): Path, Json(req): Json, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(id))) .set(billing_invoices::updated_at.eq(now)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; if let Some(customer_name) = req.customer_name { diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(id))) .set(billing_invoices::customer_name.eq(customer_name)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } if let Some(customer_email) = req.customer_email { diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(id))) .set(billing_invoices::customer_email.eq(customer_email)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } if let Some(notes) = req.notes { diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(id))) .set(billing_invoices::notes.eq(notes)) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } let invoice: BillingInvoice = billing_invoices::table .filter(billing_invoices::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; Ok(Json(invoice)) } pub async fn send_invoice( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(id))) .set(( billing_invoices::status.eq("sent"), billing_invoices::sent_at.eq(Some(now)), billing_invoices::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; let invoice: BillingInvoice = billing_invoices::table .filter(billing_invoices::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; Ok(Json(invoice)) } pub async fn void_invoice( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(id))) .set(( billing_invoices::status.eq("voided"), billing_invoices::voided_at.eq(Some(now)), billing_invoices::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; let invoice: BillingInvoice = billing_invoices::table .filter(billing_invoices::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; Ok(Json(invoice)) } pub async fn delete_invoice( State(state): State>, Path(id): Path, ) -> Result { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; diesel::delete(billing_invoices::table.filter(billing_invoices::id.eq(id))) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; Ok(StatusCode::NO_CONTENT) } pub async fn record_payment( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let id = Uuid::new_v4(); let now = Utc::now(); let payment_number = generate_payment_number(&mut conn, org_id); let payment = BillingPayment { id, org_id, bot_id, invoice_id: req.invoice_id, payment_number, amount: bd(req.amount), currency: "USD".to_string(), payment_method: req.payment_method.unwrap_or_else(|| "other".to_string()), payment_reference: req.payment_reference, status: "completed".to_string(), payer_name: req.payer_name, payer_email: req.payer_email, notes: req.notes, paid_at: now, refunded_at: None, refund_amount: None, created_at: now, }; diesel::insert_into(billing_payments::table) .values(&payment) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; if let Some(invoice_id) = req.invoice_id { let invoice: BillingInvoice = billing_invoices::table .filter(billing_invoices::id.eq(invoice_id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Invoice not found".to_string()))?; let new_paid = &invoice.amount_paid + bd(req.amount); let new_due = &invoice.total - &new_paid; let new_status = if bd_to_f64(&new_due) <= 0.0 { "paid" } else if bd_to_f64(&new_paid) > 0.0 { "partial" } else { &invoice.status }; let paid_at = if new_status == "paid" { Some(now) } else { invoice.paid_at }; diesel::update(billing_invoices::table.filter(billing_invoices::id.eq(invoice_id))) .set(( billing_invoices::amount_paid.eq(new_paid), billing_invoices::amount_due.eq(new_due), billing_invoices::status.eq(new_status), billing_invoices::paid_at.eq(paid_at), billing_invoices::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; } Ok(Json(payment)) } pub async fn list_payments( State(state): State>, Query(query): Query, ) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); let mut q = billing_payments::table .filter(billing_payments::org_id.eq(org_id)) .filter(billing_payments::bot_id.eq(bot_id)) .into_boxed(); if let Some(status) = query.status { if status != "all" { q = q.filter(billing_payments::status.eq(status)); } } let payments: Vec = q .order(billing_payments::paid_at.desc()) .limit(limit) .offset(offset) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(payments)) } pub async fn get_payment( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let payment: BillingPayment = billing_payments::table .filter(billing_payments::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Payment not found".to_string()))?; Ok(Json(payment)) } pub async fn create_quote( State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let id = Uuid::new_v4(); let now = Utc::now(); let quote_number = generate_quote_number(&mut conn, org_id); let issue_date = req .issue_date .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()) .unwrap_or_else(|| now.date_naive()); let valid_until = req .valid_until .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()) .unwrap_or_else(|| issue_date + chrono::Duration::days(30)); let tax_rate = bd(req.tax_rate.unwrap_or(0.0)); let discount_percent = bd(req.discount_percent.unwrap_or(0.0)); let mut subtotal = bd(0.0); let items = req.items.unwrap_or_default(); for item in &items { let item_amount = item.quantity * item.unit_price; let item_discount = item_amount * item.discount_percent.unwrap_or(0.0) / 100.0; subtotal = subtotal + bd(item_amount - item_discount); } let discount_amount = &subtotal * &discount_percent / bd(100.0); let taxable = &subtotal - &discount_amount; let tax_amount = &taxable * &tax_rate / bd(100.0); let total = &taxable + &tax_amount; let quote = BillingQuote { id, org_id, bot_id, quote_number, customer_id: req.customer_id, customer_name: req.customer_name, customer_email: req.customer_email, customer_address: req.customer_address, status: "draft".to_string(), issue_date, valid_until, subtotal, tax_rate, tax_amount, discount_percent, discount_amount, total, currency: req.currency.unwrap_or_else(|| "USD".to_string()), notes: req.notes, terms: req.terms, accepted_at: None, rejected_at: None, converted_invoice_id: None, sent_at: None, created_at: now, updated_at: now, }; diesel::insert_into(billing_quotes::table) .values("e) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?; for (idx, item) in items.iter().enumerate() { let item_amount = item.quantity * item.unit_price; let item_discount = item_amount * item.discount_percent.unwrap_or(0.0) / 100.0; let final_amount = item_amount - item_discount; let quote_item = BillingQuoteItem { id: Uuid::new_v4(), quote_id: id, product_id: item.product_id, description: item.description.clone(), quantity: bd(item.quantity), unit_price: bd(item.unit_price), discount_percent: bd(item.discount_percent.unwrap_or(0.0)), tax_rate: bd(item.tax_rate.unwrap_or(0.0)), amount: bd(final_amount), sort_order: idx as i32, created_at: now, }; diesel::insert_into(billing_quote_items::table) .values("e_item) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert item error: {e}")))?; } Ok(Json(quote)) } pub async fn list_quotes( State(state): State>, Query(query): Query, ) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let limit = query.limit.unwrap_or(50); let offset = query.offset.unwrap_or(0); let mut q = billing_quotes::table .filter(billing_quotes::org_id.eq(org_id)) .filter(billing_quotes::bot_id.eq(bot_id)) .into_boxed(); if let Some(status) = query.status { if status != "all" { q = q.filter(billing_quotes::status.eq(status)); } } if let Some(search) = query.search { let pattern = format!("%{search}%"); q = q.filter( billing_quotes::quote_number .ilike(pattern.clone()) .or(billing_quotes::customer_name.ilike(pattern)), ); } let quotes: Vec = q .order(billing_quotes::created_at.desc()) .limit(limit) .offset(offset) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(quotes)) } pub async fn get_quote( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let quote: BillingQuote = billing_quotes::table .filter(billing_quotes::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Quote not found".to_string()))?; let items: Vec = billing_quote_items::table .filter(billing_quote_items::quote_id.eq(id)) .order(billing_quote_items::sort_order.asc()) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(QuoteWithItems { quote, items })) } pub async fn accept_quote( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); diesel::update(billing_quotes::table.filter(billing_quotes::id.eq(id))) .set(( billing_quotes::status.eq("accepted"), billing_quotes::accepted_at.eq(Some(now)), billing_quotes::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; let quote: BillingQuote = billing_quotes::table .filter(billing_quotes::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Quote not found".to_string()))?; Ok(Json(quote)) } pub async fn reject_quote( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let now = Utc::now(); diesel::update(billing_quotes::table.filter(billing_quotes::id.eq(id))) .set(( billing_quotes::status.eq("rejected"), billing_quotes::rejected_at.eq(Some(now)), billing_quotes::updated_at.eq(now), )) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?; let quote: BillingQuote = billing_quotes::table .filter(billing_quotes::id.eq(id)) .first(&mut conn) .map_err(|_| (StatusCode::NOT_FOUND, "Quote not found".to_string()))?; Ok(Json(quote)) } pub async fn delete_quote( State(state): State>, Path(id): Path, ) -> Result { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; diesel::delete(billing_quotes::table.filter(billing_quotes::id.eq(id))) .execute(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?; Ok(StatusCode::NO_CONTENT) } pub async fn get_billing_stats( State(state): State>, ) -> Result, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let today = Utc::now().date_naive(); let invoices: Vec = billing_invoices::table .filter(billing_invoices::org_id.eq(org_id)) .filter(billing_invoices::bot_id.eq(bot_id)) .load(&mut conn) .unwrap_or_default(); let mut total_revenue = 0.0; let mut pending_amount = 0.0; let mut overdue_amount = 0.0; let mut overdue_count = 0i64; for inv in &invoices { if inv.status == "paid" { total_revenue += bd_to_f64(&inv.total); } if inv.status != "paid" && inv.status != "voided" { pending_amount += bd_to_f64(&inv.amount_due); if inv.due_date < today { overdue_amount += bd_to_f64(&inv.amount_due); overdue_count += 1; } } } let payments: Vec = billing_payments::table .filter(billing_payments::org_id.eq(org_id)) .filter(billing_payments::bot_id.eq(bot_id)) .filter(billing_payments::status.eq("completed")) .load(&mut conn) .unwrap_or_default(); let paid_this_month: f64 = payments .iter() .filter(|p| p.paid_at.date_naive().month() == today.month() && p.paid_at.date_naive().year() == today.year()) .map(|p| bd_to_f64(&p.amount)) .sum(); let revenue_this_month: f64 = invoices .iter() .filter(|i| i.status == "paid" && i.paid_at.map(|d| d.date_naive().month() == today.month() && d.date_naive().year() == today.year()).unwrap_or(false)) .map(|i| bd_to_f64(&i.total)) .sum(); let stats = BillingStats { total_revenue, revenue_this_month, pending_amount, overdue_amount, paid_this_month, invoice_count: invoices.len() as i64, payment_count: payments.len() as i64, overdue_count, }; Ok(Json(stats)) } pub async fn list_overdue_invoices( State(state): State>, ) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let today = Utc::now().date_naive(); let invoices: Vec = billing_invoices::table .filter(billing_invoices::org_id.eq(org_id)) .filter(billing_invoices::bot_id.eq(bot_id)) .filter(billing_invoices::status.ne("paid")) .filter(billing_invoices::status.ne("voided")) .filter(billing_invoices::due_date.lt(today)) .order(billing_invoices::due_date.asc()) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(invoices)) } pub async fn list_tax_rates( State(state): State>, ) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let rates: Vec = billing_tax_rates::table .filter(billing_tax_rates::org_id.eq(org_id)) .filter(billing_tax_rates::bot_id.eq(bot_id)) .filter(billing_tax_rates::is_active.eq(true)) .order(billing_tax_rates::name.asc()) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(rates)) } pub async fn list_recurring( State(state): State>, ) -> Result>, (StatusCode, String)> { let mut conn = state.conn.get().map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")) })?; let (org_id, bot_id) = get_bot_context(&state); let recurring: Vec = billing_recurring::table .filter(billing_recurring::org_id.eq(org_id)) .filter(billing_recurring::bot_id.eq(bot_id)) .filter(billing_recurring::status.eq("active")) .order(billing_recurring::next_invoice_date.asc()) .load(&mut conn) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?; Ok(Json(recurring)) } pub fn configure_billing_api_routes() -> Router> { Router::new() .route("/api/billing/invoices", get(list_invoices).post(create_invoice)) .route("/api/billing/invoices/overdue", get(list_overdue_invoices)) .route("/api/billing/invoices/:id", get(get_invoice).put(update_invoice).delete(delete_invoice)) .route("/api/billing/invoices/:id/send", put(send_invoice)) .route("/api/billing/invoices/:id/void", put(void_invoice)) .route("/api/billing/payments", get(list_payments).post(record_payment)) .route("/api/billing/payments/:id", get(get_payment)) .route("/api/billing/quotes", get(list_quotes).post(create_quote)) .route("/api/billing/quotes/:id", get(get_quote).delete(delete_quote)) .route("/api/billing/quotes/:id/accept", put(accept_quote)) .route("/api/billing/quotes/:id/reject", put(reject_quote)) .route("/api/billing/stats", get(get_billing_stats)) .route("/api/billing/tax-rates", get(list_tax_rates)) .route("/api/billing/recurring", get(list_recurring)) }