1112 lines
36 KiB
Rust
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("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<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))
|
|
}
|