- Profile photo upload: POST /api/profile/photo for all roles, stores via B2 storage - PDF resume: auto-generated from job seeker portfolio on every profile save (printpdf) - Company applications: enriched with applicant name, avatar, headline, skills, education - AI auto-apply cron: rewrote run_auto_apply with correct schema (job_seeker_profiles, cover_note, ai_auto_apply_settings, ai_auto_apply_logs, credit deduction) - Schema fix: job_seeker_profiles table name (was incorrectly 'job_seekers' in two places) - Migration: add resume_url column to job_seeker_profiles - Migrations: PayU rename, tracecoin hardening, lead reserve linkage, invoice/wallet crates - PayU integration: ai_credits, packages, admin payment handlers - Wallet and invoice crates added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
621 lines
22 KiB
Rust
621 lines
22 KiB
Rust
//! 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<LineItem>,
|
|
pub discount_amount: i64,
|
|
pub discount_label: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub customer: BillingDetails,
|
|
pub seller: SellerDetails,
|
|
pub inter_state: bool,
|
|
pub pdf_object_key: Option<String>,
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
// 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<String>,
|
|
pub seller_pan: Option<String>,
|
|
pub seller_state_code: Option<String>,
|
|
pub place_of_supply_state: Option<String>,
|
|
pub customer_name: Option<String>,
|
|
pub customer_email: Option<String>,
|
|
pub customer_phone: Option<String>,
|
|
pub customer_billing_address: Option<String>,
|
|
pub customer_gstin: Option<String>,
|
|
pub customer_state_code: Option<String>,
|
|
pub discount_label: Option<String>,
|
|
pub notes: Option<String>,
|
|
pub pdf_object_key: Option<String>,
|
|
pub issued_at: DateTime<Utc>,
|
|
pub paid_at: Option<DateTime<Utc>>,
|
|
pub voided_at: Option<DateTime<Utc>>,
|
|
pub voided_by_user_id: Option<Uuid>,
|
|
pub void_reason: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<InvoiceRow> 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<String>,
|
|
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<serde_json::Value>,
|
|
}
|
|
|
|
impl From<LineItemRow> 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<Invoice> {
|
|
// 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<Option<Invoice>> {
|
|
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<Option<Invoice>> {
|
|
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<Vec<Invoice>> {
|
|
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<Vec<Invoice>> {
|
|
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<Vec<LineItem>> {
|
|
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<Option<BillingDetails>> {
|
|
let row: Option<(String, Option<String>, Option<String>, Option<String>, Option<String>, String, Option<String>)> =
|
|
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>) {}
|