//! Invoice service: create, read, void, and PDF upload. //! //! The service is the single point of contact for the `invoices` table //! and the corresponding `invoice_line_items`. It is built around an //! `InvoiceRepository` so it can be unit-tested with an in-memory pool //! and so the heavy logic does not depend on axum. use crate::{ BillingDetails, GstBreakdown, Invoice, InvoiceError, InvoiceResult, InvoiceTotals, LineItem, SellerDetails, format_invoice_number, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool, Postgres, Row, Transaction}; use uuid::Uuid; // ────────────────────────────────────────────────────────────────────────── // Request DTOs // ────────────────────────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewInvoice { pub payment_id: Uuid, pub user_id: Uuid, pub currency: String, pub invoice_type: String, pub lines: Vec, pub discount_amount: i64, pub discount_label: Option, pub notes: Option, pub customer: BillingDetails, pub seller: SellerDetails, pub inter_state: bool, pub pdf_object_key: Option, } // ────────────────────────────────────────────────────────────────────────── // Database row // ────────────────────────────────────────────────────────────────────────── #[derive(Debug, FromRow)] pub struct InvoiceRow { pub id: Uuid, pub invoice_number: String, pub payment_id: Uuid, pub user_id: Uuid, pub status: String, pub currency: String, pub invoice_type: String, pub subtotal: i32, pub discount_amount: i32, pub cgst_rate: f64, pub cgst_amount: i32, pub sgst_rate: f64, pub sgst_amount: i32, pub igst_rate: f64, pub igst_amount: i32, pub total: i32, pub reverse_charge: bool, pub seller_name: String, pub seller_address: String, pub seller_gstin: Option, pub seller_pan: Option, pub seller_state_code: Option, pub place_of_supply_state: Option, pub customer_name: Option, pub customer_email: Option, pub customer_phone: Option, pub customer_billing_address: Option, pub customer_gstin: Option, pub customer_state_code: Option, pub discount_label: Option, pub notes: Option, pub pdf_object_key: Option, pub issued_at: DateTime, pub paid_at: Option>, pub voided_at: Option>, pub voided_by_user_id: Option, pub void_reason: Option, pub created_at: DateTime, pub updated_at: DateTime, } impl From for Invoice { fn from(r: InvoiceRow) -> Self { Invoice { id: r.id, invoice_number: r.invoice_number, payment_id: r.payment_id, user_id: r.user_id, status: r.status, currency: r.currency, invoice_type: r.invoice_type, subtotal: r.subtotal as i64, discount_amount: r.discount_amount as i64, cgst_rate: r.cgst_rate, cgst_amount: r.cgst_amount as i64, sgst_rate: r.sgst_rate, sgst_amount: r.sgst_amount as i64, igst_rate: r.igst_rate, igst_amount: r.igst_amount as i64, total: r.total as i64, reverse_charge: r.reverse_charge, seller_name: r.seller_name, seller_address: r.seller_address, seller_gstin: r.seller_gstin, seller_pan: r.seller_pan, seller_state_code: r.seller_state_code, place_of_supply_state: r.place_of_supply_state, customer_name: r.customer_name, customer_email: r.customer_email, customer_phone: r.customer_phone, customer_billing_address: r.customer_billing_address, customer_gstin: r.customer_gstin, customer_state_code: r.customer_state_code, discount_label: r.discount_label, notes: r.notes, pdf_object_key: r.pdf_object_key, issued_at: r.issued_at, paid_at: r.paid_at, voided_at: r.voided_at, voided_by_user_id: r.voided_by_user_id, void_reason: r.void_reason, created_at: r.created_at, updated_at: r.updated_at, } } } #[derive(Debug, FromRow)] pub struct LineItemRow { pub id: Uuid, pub invoice_id: Uuid, pub line_number: i32, pub description: String, pub hsn_sac_code: Option, pub quantity: f64, pub unit_price_inr: i32, pub line_subtotal_inr: i32, pub tax_rate: f64, pub line_tax_inr: i32, pub line_total_inr: i32, pub metadata: Option, } impl From for LineItem { fn from(r: LineItemRow) -> Self { LineItem { line_number: r.line_number, description: r.description, hsn_sac_code: r.hsn_sac_code, quantity: r.quantity, unit_price_paise: r.unit_price_inr as i64, tax_rate_percent: r.tax_rate, metadata: r.metadata, } } } // ────────────────────────────────────────────────────────────────────────── // Service // ────────────────────────────────────────────────────────────────────────── pub struct InvoiceService; impl InvoiceService { /// Create a new invoice. The invoice number is allocated atomically /// from the `invoice_number_seq` sequence, so concurrent calls do not /// race the same number. pub async fn create( pool: &PgPool, new: NewInvoice, ) -> InvoiceResult { // Basic validation. if new.lines.is_empty() { return Err(InvoiceError::InvalidInput( "invoice must have at least one line item".to_string(), )); } if new.discount_amount < 0 { return Err(InvoiceError::InvalidAmount(new.discount_amount)); } if new.customer.legal_name.trim().is_empty() { return Err(InvoiceError::InvalidInput( "customer.legal_name is required".to_string(), )); } if new.seller.name.trim().is_empty() { return Err(InvoiceError::InvalidInput( "seller.name is required".to_string(), )); } // Compute totals. let combined_rate = new.lines[0].tax_rate_percent; let subtotal: i64 = new.lines.iter().map(|l| l.subtotal_paise()).sum(); let taxable = (subtotal - new.discount_amount).max(0); let tax = GstBreakdown::compute(taxable, combined_rate, new.inter_state); let total = taxable + tax.total_tax_paise; let totals = InvoiceTotals { subtotal, discount: new.discount_amount, taxable_value: tax.taxable_value_paise, cgst: tax.cgst_amount, sgst: tax.sgst_amount, igst: tax.igst_amount, total_tax: tax.total_tax_paise, total, }; let mut tx = pool.begin().await?; // Allocate next sequence number and format the invoice number. let seq: i64 = sqlx::query_scalar("SELECT nextval('invoice_number_seq')") .fetch_one(&mut *tx) .await?; let invoice_number = format_invoice_number( Utc::now().format("%Y").to_string().parse().unwrap_or(2026), new.customer.state_code.as_deref(), seq, ); // Insert the invoice row. let invoice_id = Uuid::new_v4(); sqlx::query( r#" INSERT INTO invoices ( id, invoice_number, payment_id, user_id, status, currency, invoice_type, subtotal, discount_amount, discount_label, notes, cgst_rate, cgst_amount, sgst_rate, sgst_amount, igst_rate, igst_amount, total, reverse_charge, seller_name, seller_address, seller_gstin, seller_pan, seller_state_code, place_of_supply_state, customer_name, customer_email, customer_phone, customer_billing_address, customer_gstin, customer_state_code, pdf_object_key, issued_at ) VALUES ( $1, $2, $3, $4, 'ISSUED', $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, NOW() ) "#, ) .bind(invoice_id) .bind(&invoice_number) .bind(new.payment_id) .bind(new.user_id) .bind(&new.currency) .bind(&new.invoice_type) .bind(totals.subtotal as i32) .bind(totals.discount as i32) .bind(new.discount_label.as_deref()) .bind(new.notes.as_deref()) .bind(tax.cgst_rate) .bind(tax.cgst_amount as i32) .bind(tax.sgst_rate) .bind(tax.sgst_amount as i32) .bind(tax.igst_rate) .bind(tax.igst_amount as i32) .bind(totals.total as i64) .bind(new.inter_state) .bind(&new.seller.name) .bind(&new.seller.address) .bind(new.seller.gstin.as_deref()) .bind(new.seller.pan.as_deref()) .bind(&new.seller.state_code) .bind(new.customer.state_code.as_deref()) .bind(&new.customer.legal_name) .bind(new.customer.email.as_deref()) .bind(new.customer.phone.as_deref()) .bind(&new.customer.billing_address) .bind(new.customer.gstin.as_deref()) .bind(new.customer.state_code.as_deref()) .bind(new.pdf_object_key.as_deref()) .execute(&mut *tx) .await?; // Insert line items. for line in &new.lines { sqlx::query( r#" INSERT INTO invoice_line_items ( invoice_id, line_number, description, hsn_sac_code, quantity, unit_price_inr, line_subtotal_inr, tax_rate, line_tax_inr, line_total_inr, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) "#, ) .bind(invoice_id) .bind(line.line_number) .bind(&line.description) .bind(line.hsn_sac_code.as_deref()) .bind(line.quantity) .bind(line.unit_price_paise as i64) .bind(line.subtotal_paise() as i64) .bind(line.tax_rate_percent) .bind(line.tax_paise() as i64) .bind(line.total_paise() as i64) .bind(line.metadata.as_ref()) .execute(&mut *tx) .await?; } tx.commit().await?; Self::get(pool, invoice_id) .await? .ok_or(InvoiceError::NotFound) } pub async fn get( pool: &PgPool, id: Uuid, ) -> InvoiceResult> { let row = sqlx::query_as::<_, InvoiceRow>( r#" SELECT id, invoice_number, payment_id, user_id, status, currency, invoice_type, subtotal, discount_amount, cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, igst_rate::FLOAT8 AS igst_rate, igst_amount, total, reverse_charge, seller_name, seller_address, seller_gstin, seller_pan, seller_state_code, place_of_supply_state, customer_name, customer_email, customer_phone, customer_billing_address, customer_gstin, customer_state_code, discount_label, notes, pdf_object_key, issued_at, paid_at, voided_at, voided_by_user_id, void_reason, created_at, updated_at FROM invoices WHERE id = $1 "#, ) .bind(id) .fetch_optional(pool) .await?; Ok(row.map(Into::into)) } pub async fn get_by_payment( pool: &PgPool, payment_id: Uuid, ) -> InvoiceResult> { let row = sqlx::query_as::<_, InvoiceRow>( r#" SELECT id, invoice_number, payment_id, user_id, status, currency, invoice_type, subtotal, discount_amount, cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, igst_rate::FLOAT8 AS igst_rate, igst_amount, total, reverse_charge, seller_name, seller_address, seller_gstin, seller_pan, seller_state_code, place_of_supply_state, customer_name, customer_email, customer_phone, customer_billing_address, customer_gstin, customer_state_code, discount_label, notes, pdf_object_key, issued_at, paid_at, voided_at, voided_by_user_id, void_reason, created_at, updated_at FROM invoices WHERE payment_id = $1 ORDER BY issued_at DESC LIMIT 1 "#, ) .bind(payment_id) .fetch_optional(pool) .await?; Ok(row.map(Into::into)) } pub async fn list_for_user( pool: &PgPool, user_id: Uuid, page: i64, limit: i64, ) -> InvoiceResult> { let limit = limit.clamp(1, 200); let page = page.max(1); let offset = (page - 1) * limit; let rows = sqlx::query_as::<_, InvoiceRow>( r#" SELECT id, invoice_number, payment_id, user_id, status, currency, invoice_type, subtotal, discount_amount, cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, igst_rate::FLOAT8 AS igst_rate, igst_amount, total, reverse_charge, seller_name, seller_address, seller_gstin, seller_pan, seller_state_code, place_of_supply_state, customer_name, customer_email, customer_phone, customer_billing_address, customer_gstin, customer_state_code, discount_label, notes, pdf_object_key, issued_at, paid_at, voided_at, voided_by_user_id, void_reason, created_at, updated_at FROM invoices WHERE user_id = $1 ORDER BY issued_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(user_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn list_all( pool: &PgPool, status: Option<&str>, page: i64, limit: i64, ) -> InvoiceResult> { let limit = limit.clamp(1, 200); let page = page.max(1); let offset = (page - 1) * limit; let status_filter = status.map(str::to_ascii_uppercase); let rows = sqlx::query_as::<_, InvoiceRow>( r#" SELECT id, invoice_number, payment_id, user_id, status, currency, invoice_type, subtotal, discount_amount, cgst_rate::FLOAT8 AS cgst_rate, cgst_amount, sgst_rate::FLOAT8 AS sgst_rate, sgst_amount, igst_rate::FLOAT8 AS igst_rate, igst_amount, total, reverse_charge, seller_name, seller_address, seller_gstin, seller_pan, seller_state_code, place_of_supply_state, customer_name, customer_email, customer_phone, customer_billing_address, customer_gstin, customer_state_code, discount_label, notes, pdf_object_key, issued_at, paid_at, voided_at, voided_by_user_id, void_reason, created_at, updated_at FROM invoices WHERE ($1::text IS NULL OR status = $1) ORDER BY issued_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(status_filter) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn list_line_items( pool: &PgPool, invoice_id: Uuid, ) -> InvoiceResult> { let rows = sqlx::query_as::<_, LineItemRow>( r#" SELECT id, invoice_id, line_number, description, hsn_sac_code, quantity::FLOAT8 AS quantity, unit_price_inr, line_subtotal_inr, tax_rate::FLOAT8 AS tax_rate, line_tax_inr, line_total_inr, metadata FROM invoice_line_items WHERE invoice_id = $1 ORDER BY line_number ASC "#, ) .bind(invoice_id) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } pub async fn mark_paid( pool: &PgPool, id: Uuid, ) -> InvoiceResult<()> { let result = sqlx::query( r#" UPDATE invoices SET status = 'PAID', paid_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'ISSUED' "#, ) .bind(id) .execute(pool) .await?; if result.rows_affected() == 0 { return Err(InvoiceError::IllegalState("not ISSUED".to_string())); } Ok(()) } pub async fn void( pool: &PgPool, id: Uuid, by_user_id: Uuid, reason: &str, ) -> InvoiceResult<()> { if reason.trim().is_empty() { return Err(InvoiceError::InvalidInput("void reason is required".to_string())); } let result = sqlx::query( r#" UPDATE invoices SET status = 'VOID', voided_at = NOW(), voided_by_user_id = $2, void_reason = $3, updated_at = NOW() WHERE id = $1 AND status IN ('ISSUED', 'PAID') "#, ) .bind(id) .bind(by_user_id) .bind(reason) .execute(pool) .await?; if result.rows_affected() == 0 { return Err(InvoiceError::IllegalState("not ISSUED or PAID".to_string())); } Ok(()) } pub async fn attach_pdf( pool: &PgPool, id: Uuid, object_key: &str, ) -> InvoiceResult<()> { sqlx::query( r#" UPDATE invoices SET pdf_object_key = $2, updated_at = NOW() WHERE id = $1 "#, ) .bind(id) .bind(object_key) .execute(pool) .await?; Ok(()) } /// Compute totals from a stored invoice + its line items. pub fn totals(invoice: &Invoice, lines: &[LineItem]) -> InvoiceTotals { crate::compute_totals(lines, invoice.discount_amount, invoice.igst_amount > 0) } } // ────────────────────────────────────────────────────────────────────────── // Billing profile repository // ────────────────────────────────────────────────────────────────────────── pub struct BillingProfileRepo; impl BillingProfileRepo { pub async fn upsert_default( pool: &PgPool, user_id: Uuid, profile: &BillingDetails, ) -> InvoiceResult<()> { let mut tx = pool.begin().await?; // Clear existing default. sqlx::query( r#" UPDATE billing_profiles SET is_default = false, updated_at = NOW() WHERE user_id = $1 AND is_default = true "#, ) .bind(user_id) .execute(&mut *tx) .await?; // Insert new default. sqlx::query( r#" INSERT INTO billing_profiles ( user_id, legal_name, email, phone, gstin, pan, billing_address, state_code, is_default ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) "#, ) .bind(user_id) .bind(&profile.legal_name) .bind(profile.email.as_deref()) .bind(profile.phone.as_deref()) .bind(profile.gstin.as_deref()) .bind(profile.pan.as_deref()) .bind(&profile.billing_address) .bind(profile.state_code.as_deref()) .execute(&mut *tx) .await?; tx.commit().await?; Ok(()) } pub async fn get_default( pool: &PgPool, user_id: Uuid, ) -> InvoiceResult> { let row: Option<(String, Option, Option, Option, Option, String, Option)> = sqlx::query_as( r#" SELECT legal_name, email, phone, gstin, pan, billing_address, state_code FROM billing_profiles WHERE user_id = $1 AND is_default = true "#, ) .bind(user_id) .fetch_optional(pool) .await?; Ok(row.map(|(n, e, p, g, pa, a, s)| BillingDetails { legal_name: n, email: e, phone: p, gstin: g, pan: pa, billing_address: a, state_code: s, })) } } // Suppress unused warning for Transaction import in non-tx code paths. #[allow(dead_code)] fn _phantom_tx<'a>(_: &Transaction<'a, Postgres>) {}