generalbots/src/billing/api.rs

1112 lines
36 KiB
Rust

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<Uuid>,
pub customer_name: String,
pub customer_email: Option<String>,
pub customer_address: Option<String>,
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<String>,
pub terms: Option<String>,
pub footer: Option<String>,
pub paid_at: Option<DateTime<Utc>>,
pub sent_at: Option<DateTime<Utc>>,
pub voided_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<Uuid>,
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<Utc>,
}
#[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<Uuid>,
pub payment_number: String,
pub amount: BigDecimal,
pub currency: String,
pub payment_method: String,
pub payment_reference: Option<String>,
pub status: String,
pub payer_name: Option<String>,
pub payer_email: Option<String>,
pub notes: Option<String>,
pub paid_at: DateTime<Utc>,
pub refunded_at: Option<DateTime<Utc>>,
pub refund_amount: Option<BigDecimal>,
pub created_at: DateTime<Utc>,
}
#[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<Uuid>,
pub customer_name: String,
pub customer_email: Option<String>,
pub customer_address: Option<String>,
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<String>,
pub terms: Option<String>,
pub accepted_at: Option<DateTime<Utc>>,
pub rejected_at: Option<DateTime<Utc>>,
pub converted_invoice_id: Option<Uuid>,
pub sent_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<Uuid>,
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<Utc>,
}
#[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<Uuid>,
pub customer_name: String,
pub customer_email: Option<String>,
pub status: String,
pub frequency: String,
pub interval_count: i32,
pub amount: BigDecimal,
pub currency: String,
pub description: Option<String>,
pub next_invoice_date: NaiveDate,
pub last_invoice_date: Option<NaiveDate>,
pub last_invoice_id: Option<Uuid>,
pub start_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub invoices_generated: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
pub region: Option<String>,
pub is_default: bool,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateInvoiceRequest {
pub customer_name: String,
pub customer_email: Option<String>,
pub customer_address: Option<String>,
pub customer_id: Option<Uuid>,
pub issue_date: Option<String>,
pub due_date: Option<String>,
pub tax_rate: Option<f64>,
pub discount_percent: Option<f64>,
pub currency: Option<String>,
pub notes: Option<String>,
pub terms: Option<String>,
pub items: Option<Vec<InvoiceItemRequest>>,
}
#[derive(Debug, Deserialize)]
pub struct InvoiceItemRequest {
pub description: String,
pub quantity: f64,
pub unit_price: f64,
pub discount_percent: Option<f64>,
pub tax_rate: Option<f64>,
pub product_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateInvoiceRequest {
pub customer_name: Option<String>,
pub customer_email: Option<String>,
pub customer_address: Option<String>,
pub due_date: Option<String>,
pub tax_rate: Option<f64>,
pub discount_percent: Option<f64>,
pub notes: Option<String>,
pub terms: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RecordPaymentRequest {
pub invoice_id: Option<Uuid>,
pub amount: f64,
pub payment_method: Option<String>,
pub payment_reference: Option<String>,
pub payer_name: Option<String>,
pub payer_email: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateQuoteRequest {
pub customer_name: String,
pub customer_email: Option<String>,
pub customer_address: Option<String>,
pub customer_id: Option<Uuid>,
pub issue_date: Option<String>,
pub valid_until: Option<String>,
pub tax_rate: Option<f64>,
pub discount_percent: Option<f64>,
pub currency: Option<String>,
pub notes: Option<String>,
pub terms: Option<String>,
pub items: Option<Vec<InvoiceItemRequest>>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub search: Option<String>,
pub status: Option<String>,
pub customer_id: Option<Uuid>,
pub from_date: Option<String>,
pub to_date: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[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<BillingInvoiceItem>,
}
#[derive(Debug, Serialize)]
pub struct QuoteWithItems {
pub quote: BillingQuote,
pub items: Vec<BillingQuoteItem>,
}
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::<f64>().unwrap_or(0.0)
}
pub async fn create_invoice(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateInvoiceRequest>,
) -> Result<Json<BillingInvoice>, (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<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<BillingInvoice>>, (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<BillingInvoice> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<InvoiceWithItems>, (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<BillingInvoiceItem> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateInvoiceRequest>,
) -> Result<Json<BillingInvoice>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<BillingInvoice>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<BillingInvoice>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<Arc<AppState>>,
Json(req): Json<RecordPaymentRequest>,
) -> Result<Json<BillingPayment>, (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<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<BillingPayment>>, (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<BillingPayment> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<BillingPayment>, (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<Arc<AppState>>,
Json(req): Json<CreateQuoteRequest>,
) -> Result<Json<BillingQuote>, (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(&quote)
.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(&quote_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<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<BillingQuote>>, (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<BillingQuote> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<QuoteWithItems>, (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<BillingQuoteItem> = 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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<BillingQuote>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<BillingQuote>, (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<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
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<Arc<AppState>>,
) -> Result<Json<BillingStats>, (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<BillingInvoice> = 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<BillingPayment> = 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<Arc<AppState>>,
) -> Result<Json<Vec<BillingInvoice>>, (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<BillingInvoice> = 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<Arc<AppState>>,
) -> Result<Json<Vec<BillingTaxRate>>, (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<BillingTaxRate> = 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<Arc<AppState>>,
) -> Result<Json<Vec<BillingRecurring>>, (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<BillingRecurring> = 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<Arc<AppState>> {
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))
}