modulo de ventas

This commit is contained in:
2026-02-05 22:34:58 -04:00
parent dacd50b451
commit 495c09979a
8 changed files with 930 additions and 1 deletions

View File

@@ -38,6 +38,8 @@ async fn main() -> std::io::Result<()> {
.configure(modules::auth::routes::configure)
.configure(modules::users::routes::configure)
.configure(modules::catalog::routes::configure)
.configure(modules::sales::routes::configure)
})
.bind((bind_host.as_str(), bind_port))?

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod users;
pub mod catalog;
pub mod catalog;
pub mod sales;

View File

@@ -0,0 +1,87 @@
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
code: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
#[derive(Debug, Error)]
pub enum SalesError {
#[error("Datos inválidos")]
BadRequest,
#[error("Datos inválidos")]
BadRequestDetail(String),
#[error("No encontrado")]
NotFound,
#[error("Sin stock suficiente")]
OutOfStock,
#[error("No autorizado")]
Forbidden,
#[error("Ocurrió un error inesperado")]
Internal,
}
impl SalesError {
fn code(&self) -> &'static str {
match self {
SalesError::BadRequest | SalesError::BadRequestDetail(_) => "bad_request",
SalesError::NotFound => "not_found",
SalesError::OutOfStock => "out_of_stock",
SalesError::Forbidden => "forbidden",
SalesError::Internal => "internal_error",
}
}
fn detail(&self) -> Option<String> {
match self {
SalesError::BadRequest => Some("Revisa los campos requeridos e inténtalo nuevamente.".to_string()),
SalesError::BadRequestDetail(s) => Some(s.clone()),
SalesError::NotFound => Some("No existe en tu empresa.".to_string()),
SalesError::OutOfStock => Some("No hay inventario suficiente para completar la venta.".to_string()),
SalesError::Forbidden => Some("No tienes permisos para esta acción.".to_string()),
SalesError::Internal => None,
}
}
fn status(&self) -> StatusCode {
match self {
SalesError::BadRequest | SalesError::BadRequestDetail(_) => StatusCode::BAD_REQUEST,
SalesError::NotFound => StatusCode::NOT_FOUND,
SalesError::OutOfStock => StatusCode::CONFLICT,
SalesError::Forbidden => StatusCode::FORBIDDEN,
SalesError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl ResponseError for SalesError {
fn status_code(&self) -> StatusCode { self.status() }
fn error_response(&self) -> HttpResponse {
let body = ErrorBody {
error: self.to_string(),
code: self.code(),
detail: self.detail(),
};
HttpResponse::build(self.status_code()).json(body)
}
}
impl From<crate::middleware::errors::ApiAuthError> for SalesError {
fn from(e: crate::middleware::errors::ApiAuthError) -> Self {
match e {
crate::middleware::errors::ApiAuthError::Forbidden => SalesError::Forbidden,
_ => SalesError::Internal,
}
}
}

View File

@@ -0,0 +1,44 @@
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use crate::middleware::{authz::require_roles, jwt::AuthenticatedUser};
use super::{errors::SalesError, models::SaleCreateReq, service};
pub async fn list(
pool: web::Data<PgPool>,
user: AuthenticatedUser,
) -> Result<HttpResponse, SalesError> {
require_roles(&user, &["ADMIN", "CASHIER"])?;
Ok(HttpResponse::Ok().json(service::list_sales(&pool, user.company_id).await?))
}
pub async fn get_by_id(
pool: web::Data<PgPool>,
user: AuthenticatedUser,
path: web::Path<i64>,
) -> Result<HttpResponse, SalesError> {
require_roles(&user, &["ADMIN", "CASHIER"])?;
Ok(HttpResponse::Ok().json(service::get_sale(&pool, user.company_id, path.into_inner()).await?))
}
pub async fn create(
pool: web::Data<PgPool>,
user: AuthenticatedUser,
body: web::Json<SaleCreateReq>,
) -> Result<HttpResponse, SalesError> {
require_roles(&user, &["ADMIN", "CASHIER"])?;
Ok(HttpResponse::Created().json(
service::create_sale(&pool, user.company_id, user.user_id, body.into_inner()).await?
))
}
pub async fn cancel(
pool: web::Data<PgPool>,
user: AuthenticatedUser,
path: web::Path<i64>,
) -> Result<HttpResponse, SalesError> {
require_roles(&user, &["ADMIN"])?;
service::cancel_sale(&pool, user.company_id, user.user_id, path.into_inner()).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({ "ok": true })))
}

5
src/modules/sales/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod routes;
mod handlers;
mod service;
mod models;
mod errors;

View File

@@ -0,0 +1,77 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct SaleCreateReq {
pub warehouse_id: Option<i64>, // si null => default
pub items: Vec<SaleItemReq>,
pub payments: Vec<SalePaymentReq>,
}
#[derive(Debug, Deserialize)]
pub struct SaleItemReq {
pub product_id: i64,
pub quantity: i64, // (si luego quieres 14,3 lo pasamos a Decimal)
pub unit_price_cents: Option<i64>, // si null => toma precio activo
}
#[derive(Debug, Deserialize)]
pub struct SalePaymentReq {
pub method_code: String, // "CASH" | "CARD" | "TRANSFER"
pub amount_cents: i64,
}
#[derive(Debug, Serialize)]
pub struct SaleListDto {
pub id: i64,
pub invoice_number: Option<String>,
pub status_code: String,
pub sold_at: Option<DateTime<Utc>>,
pub total_cents: i64,
pub paid_cents: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct SaleDto {
pub id: i64,
pub invoice_number: Option<String>,
pub status_code: String,
pub cashier_user_id: Option<i64>,
pub sold_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub subtotal_cents: i64,
pub tax_cents: i64,
pub total_cents: i64,
pub paid_cents: i64,
pub balance_cents: i64,
pub change_cents: i64,
pub items: Vec<SaleItemDto>,
pub taxes: Vec<SaleTaxDto>,
pub payments: Vec<SalePaymentDto>,
}
#[derive(Debug, Serialize)]
pub struct SaleItemDto {
pub product_id: i64,
pub quantity: i64,
pub unit_price_cents: i64,
pub line_total_cents: i64,
}
#[derive(Debug, Serialize)]
pub struct SaleTaxDto {
pub tax_id: i64,
pub rate_bp: i32, // 16% => 1600
pub base_cents: i64,
pub tax_cents: i64,
}
#[derive(Debug, Serialize)]
pub struct SalePaymentDto {
pub method_code: String,
pub amount_cents: i64,
}

View File

@@ -0,0 +1,12 @@
use actix_web::web;
use super::handlers;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/sales")
.route("", web::post().to(handlers::create))
.route("", web::get().to(handlers::list))
.route("/{id}", web::get().to(handlers::get_by_id))
.route("/{id}/cancel", web::put().to(handlers::cancel)),
);
}

View File

@@ -0,0 +1,701 @@
use chrono::Utc;
use sqlx::{PgPool, Postgres, Transaction};
use super::{errors::SalesError, models::*};
fn map_db_error(e: sqlx::Error) -> SalesError {
if let sqlx::Error::Database(db_err) = &e {
let code = db_err.code().map(|c| c.to_string()).unwrap_or_default();
let msg = db_err.message().to_string();
let constraint = db_err.constraint().unwrap_or("").to_string();
tracing::error!(
pg_code = %code,
pg_constraint = %constraint,
pg_message = %msg,
"DB error in sales module"
);
// Unique
if code == "23505" {
return SalesError::BadRequestDetail("Duplicado (unique)".to_string());
}
// FK
if code == "23503" {
return SalesError::BadRequestDetail("Referencia inválida (FK)".to_string());
}
// NOT NULL
if code == "23502" {
return SalesError::BadRequestDetail("Falta un campo obligatorio (NOT NULL)".to_string());
}
// CHECK
if code == "23514" {
return SalesError::BadRequestDetail("Datos inválidos: violación de restricción (CHECK)".to_string());
}
// En desarrollo: si cae aquí, te dejo el mensaje real (para que lo veas en Postman)
return SalesError::BadRequestDetail(format!("DB {}: {}", code, msg));
}
tracing::error!(error = %e, "Non-DB error in sales module");
SalesError::Internal
}
async fn get_id_by_code(
tx: &mut Transaction<'_, Postgres>,
table: &str,
code: &str,
) -> Result<i64, SalesError> {
let q = format!("SELECT id::bigint FROM {} WHERE code = $1 LIMIT 1", table);
let id: Option<i64> = sqlx::query_scalar(&q)
.bind(code)
.fetch_optional(&mut **tx)
.await
.map_err(map_db_error)?;
id.ok_or_else(|| SalesError::BadRequestDetail(format!("No existe code='{}' en {}", code, table)))
}
async fn get_default_warehouse(tx: &mut Transaction<'_, Postgres>, company_id: i64) -> Result<i64, SalesError> {
let wh: Option<i64> = sqlx::query_scalar(
r#"
SELECT id::bigint
FROM pos.warehouse
WHERE company_id=$1 AND is_default=TRUE
ORDER BY id LIMIT 1
"#,
)
.bind(company_id)
.fetch_optional(&mut **tx)
.await
.map_err(map_db_error)?;
if let Some(id) = wh { return Ok(id); }
let any: Option<i64> = sqlx::query_scalar(
r#"SELECT id::bigint FROM pos.warehouse WHERE company_id=$1 ORDER BY id LIMIT 1"#,
)
.bind(company_id)
.fetch_optional(&mut **tx)
.await
.map_err(map_db_error)?;
if let Some(id) = any {
sqlx::query(r#"UPDATE pos.warehouse SET is_default=TRUE WHERE id=$1"#)
.bind(id)
.execute(&mut **tx)
.await
.map_err(map_db_error)?;
return Ok(id);
}
let new_id: i64 = sqlx::query_scalar(
r#"INSERT INTO pos.warehouse(company_id,name,is_default) VALUES ($1,'Default',TRUE) RETURNING id::bigint"#,
)
.bind(company_id)
.fetch_one(&mut **tx)
.await
.map_err(map_db_error)?;
Ok(new_id)
}
async fn ensure_product_ok(tx: &mut Transaction<'_, Postgres>, company_id: i64, product_id: i64) -> Result<(), SalesError> {
let ok: Option<i64> = sqlx::query_scalar(
r#"SELECT id::bigint FROM pos.product WHERE company_id=$1 AND id=$2 AND is_active=TRUE LIMIT 1"#,
)
.bind(company_id)
.bind(product_id)
.fetch_optional(&mut **tx)
.await
.map_err(map_db_error)?;
if ok.is_some() { Ok(()) } else { Err(SalesError::BadRequestDetail("Producto inválido o inactivo".to_string())) }
}
async fn get_active_price_cents(tx: &mut Transaction<'_, Postgres>, product_id: i64) -> Result<i64, SalesError> {
let p: Option<i64> = sqlx::query_scalar(
r#"
SELECT COALESCE(ROUND(unit_price * 100)::bigint, 0)
FROM pos.product_price
WHERE product_id=$1 AND valid_to IS NULL
ORDER BY valid_from DESC
LIMIT 1
"#,
)
.bind(product_id)
.fetch_optional(&mut **tx)
.await
.map_err(map_db_error)?;
Ok(p.unwrap_or(0))
}
async fn stock_decrease(
tx: &mut Transaction<'_, Postgres>,
warehouse_id: i64,
product_id: i64,
qty: i64,
) -> Result<(), SalesError> {
// UPDATE con guardia qty_on_hand >= qty
let updated: Option<i64> = sqlx::query_scalar(
r#"
UPDATE pos.inventory_stock
SET qty_on_hand = qty_on_hand - ($3::numeric)
WHERE warehouse_id=$1 AND product_id=$2
AND qty_on_hand >= ($3::numeric)
RETURNING 1::bigint
"#,
)
.bind(warehouse_id)
.bind(product_id)
.bind(qty)
.fetch_optional(&mut **tx)
.await
.map_err(map_db_error)?;
if updated.is_none() { return Err(SalesError::OutOfStock); }
Ok(())
}
async fn stock_increase(
tx: &mut Transaction<'_, Postgres>,
warehouse_id: i64,
product_id: i64,
qty: i64,
) -> Result<(), SalesError> {
sqlx::query(
r#"
INSERT INTO pos.inventory_stock(warehouse_id, product_id, qty_on_hand)
VALUES ($1, $2, ($3::numeric))
ON CONFLICT (warehouse_id, product_id)
DO UPDATE SET qty_on_hand = pos.inventory_stock.qty_on_hand + EXCLUDED.qty_on_hand
"#,
)
.bind(warehouse_id)
.bind(product_id)
.bind(qty)
.execute(&mut **tx)
.await
.map_err(map_db_error)?;
Ok(())
}
pub async fn create_sale(
pool: &PgPool,
company_id: i64,
cashier_user_id: i64,
req: SaleCreateReq,
) -> Result<SaleDto, SalesError> {
if req.items.is_empty() {
return Err(SalesError::BadRequestDetail("La venta debe tener items".to_string()));
}
if req.payments.is_empty() {
return Err(SalesError::BadRequestDetail("La venta debe tener pagos".to_string()));
}
let mut tx = pool.begin().await.map_err(map_db_error)?;
let warehouse_id = match req.warehouse_id {
Some(w) => w,
None => get_default_warehouse(&mut tx, company_id).await?,
};
// status ids
let status_draft_id = get_id_by_code(&mut tx, "pos.sale_status", "DRAFT").await?;
let status_completed_id = get_id_by_code(&mut tx, "pos.sale_status", "COMPLETED").await?;
let out_sale_type_id = get_id_by_code(&mut tx, "pos.inventory_movement_type", "OUT_SALE").await?;
// 1) crear sale en DRAFT
let sale_id: i64 = sqlx::query_scalar(
r#"
INSERT INTO pos.sale(company_id, status_id, cashier_user_id)
VALUES ($1, $2, $3)
RETURNING id::bigint
"#,
)
.bind(company_id)
.bind(status_draft_id)
.bind(cashier_user_id)
.fetch_one(&mut *tx)
.await
.map_err(map_db_error)?;
// 2) asignar invoice_number basado en id (garantiza unique)
let invoice_number = format!("FACT-{}-{:06}", company_id, sale_id);
sqlx::query(
r#"UPDATE pos.sale SET invoice_number=$2 WHERE id=$1"#,
)
.bind(sale_id)
.bind(&invoice_number)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
// 3) insertar items + descontar stock + kardex OUT_SALE
let mut subtotal_cents: i64 = 0;
for it in &req.items {
if it.quantity <= 0 {
let _ = tx.rollback().await;
return Err(SalesError::BadRequestDetail("quantity debe ser > 0".to_string()));
}
ensure_product_ok(&mut tx, company_id, it.product_id).await?;
let unit_price_cents = match it.unit_price_cents {
Some(v) => v,
None => get_active_price_cents(&mut tx, it.product_id).await?,
};
let line_total_cents = unit_price_cents
.checked_mul(it.quantity)
.ok_or(SalesError::Internal)?;
subtotal_cents = subtotal_cents.checked_add(line_total_cents).ok_or(SalesError::Internal)?;
// stock
stock_decrease(&mut tx, warehouse_id, it.product_id, it.quantity).await?;
// sale_item (quantity NUMERIC(14,3), unit_price NUMERIC(14,2))
sqlx::query(
r#"
INSERT INTO pos.sale_item(sale_id, product_id, quantity, unit_price)
VALUES ($1, $2, ($3::numeric), ($4::numeric / 100))
"#,
)
.bind(sale_id)
.bind(it.product_id)
.bind(it.quantity)
.bind(unit_price_cents)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
// inventory_movement (reference_table + reference_id)
sqlx::query(
r#"
INSERT INTO pos.inventory_movement(
company_id, warehouse_id, product_id, movement_type_id, qty,
reference_table, reference_id, created_by
)
VALUES ($1,$2,$3,$4,($5::numeric),'sale',$6,$7)
"#,
)
.bind(company_id)
.bind(warehouse_id)
.bind(it.product_id)
.bind(out_sale_type_id)
.bind(it.quantity)
.bind(sale_id)
.bind(cashier_user_id)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
}
// 4) impuestos: snapshot con tasas activas de la empresa (tax_rate activo)
// base_amount = subtotal, tax_amount = base * rate_pct/100
sqlx::query(
r#"
INSERT INTO pos.sale_tax(sale_id, tax_id, rate_pct, base_amount, tax_amount)
SELECT
$1 AS sale_id,
tr.tax_id,
tr.rate_pct,
(($2::numeric)/100) AS base_amount,
((($2::numeric)/100) * (tr.rate_pct/100)) AS tax_amount
FROM pos.tax_rate tr
WHERE tr.company_id = $3 AND tr.valid_to IS NULL
"#,
)
.bind(sale_id)
.bind(subtotal_cents)
.bind(company_id)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
// tax_total_cents y total_cents
let tax_cents: i64 = sqlx::query_scalar(
r#"SELECT COALESCE(ROUND(SUM(tax_amount)*100)::bigint, 0) FROM pos.sale_tax WHERE sale_id=$1"#,
)
.bind(sale_id)
.fetch_one(&mut *tx)
.await
.map_err(map_db_error)?;
let total_cents = subtotal_cents.checked_add(tax_cents).ok_or(SalesError::Internal)?;
// 5) pagos (payment_method_id)
let mut paid_cents: i64 = 0;
for p in &req.payments {
let code = p.method_code.trim().to_uppercase();
if code.is_empty() || p.amount_cents <= 0 {
let _ = tx.rollback().await;
return Err(SalesError::BadRequestDetail("Pago inválido".to_string()));
}
let pm_id = get_id_by_code(&mut tx, "pos.payment_method", &code).await?;
paid_cents = paid_cents.checked_add(p.amount_cents).ok_or(SalesError::Internal)?;
sqlx::query(
r#"
INSERT INTO pos.sale_payment(sale_id, payment_method_id, amount)
VALUES ($1, $2, (($3::numeric)/100))
"#,
)
.bind(sale_id)
.bind(pm_id)
.bind(p.amount_cents)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
}
// 6) si pagó completo => COMPLETED + sold_at
if paid_cents >= total_cents {
sqlx::query(
r#"
UPDATE pos.sale
SET status_id = $2,
sold_at = now(),
updated_at = now()
WHERE id = $1 AND company_id = $3
"#,
)
.bind(sale_id)
.bind(status_completed_id)
.bind(company_id)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
} else {
sqlx::query(r#"UPDATE pos.sale SET updated_at=now() WHERE id=$1"#)
.bind(sale_id)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
}
tx.commit().await.map_err(map_db_error)?;
get_sale(pool, company_id, sale_id).await
}
pub async fn list_sales(pool: &PgPool, company_id: i64) -> Result<Vec<SaleListDto>, SalesError> {
#[derive(sqlx::FromRow)]
struct Row {
id: i64,
invoice_number: Option<String>,
status_code: String,
sold_at: Option<chrono::DateTime<Utc>>,
total_cents: i64,
paid_cents: i64,
created_at: chrono::DateTime<Utc>,
}
let rows: Vec<Row> = sqlx::query_as(
r#"
SELECT
s.id::bigint AS id,
s.invoice_number,
st.code AS status_code,
s.sold_at,
COALESCE(ROUND((
(SELECT COALESCE(SUM(si.quantity * si.unit_price),0) FROM pos.sale_item si WHERE si.sale_id=s.id)
+
(SELECT COALESCE(SUM(tx.tax_amount),0) FROM pos.sale_tax tx WHERE tx.sale_id=s.id)
) * 100)::bigint, 0) AS total_cents,
COALESCE(ROUND((
SELECT COALESCE(SUM(sp.amount),0) FROM pos.sale_payment sp WHERE sp.sale_id=s.id
) * 100)::bigint, 0) AS paid_cents,
s.created_at
FROM pos.sale s
JOIN pos.sale_status st ON st.id = s.status_id
WHERE s.company_id = $1
ORDER BY s.id DESC
LIMIT 200
"#,
)
.bind(company_id)
.fetch_all(pool)
.await
.map_err(map_db_error)?;
Ok(rows.into_iter().map(|r| SaleListDto {
id: r.id,
invoice_number: r.invoice_number,
status_code: r.status_code,
sold_at: r.sold_at,
total_cents: r.total_cents,
paid_cents: r.paid_cents,
created_at: r.created_at,
}).collect())
}
pub async fn get_sale(pool: &PgPool, company_id: i64, sale_id: i64) -> Result<SaleDto, SalesError> {
#[derive(sqlx::FromRow)]
struct H {
id: i64,
invoice_number: Option<String>,
status_code: String,
cashier_user_id: Option<i64>,
sold_at: Option<chrono::DateTime<Utc>>,
created_at: chrono::DateTime<Utc>,
updated_at: chrono::DateTime<Utc>,
}
let h: Option<H> = sqlx::query_as(
r#"
SELECT
s.id::bigint AS id,
s.invoice_number,
st.code AS status_code,
s.cashier_user_id::bigint AS cashier_user_id,
s.sold_at,
s.created_at,
s.updated_at
FROM pos.sale s
JOIN pos.sale_status st ON st.id = s.status_id
WHERE s.company_id=$1 AND s.id=$2
LIMIT 1
"#,
)
.bind(company_id)
.bind(sale_id)
.fetch_optional(pool)
.await
.map_err(map_db_error)?;
let h = h.ok_or(SalesError::NotFound)?;
#[derive(sqlx::FromRow)]
struct I { product_id: i64, quantity_i64: i64, unit_price_cents: i64 }
let item_rows: Vec<I> = sqlx::query_as(
r#"
SELECT
product_id::bigint AS product_id,
ROUND(quantity)::bigint AS quantity_i64,
COALESCE(ROUND(unit_price*100)::bigint,0) AS unit_price_cents
FROM pos.sale_item
WHERE sale_id=$1
ORDER BY id
"#,
)
.bind(sale_id)
.fetch_all(pool)
.await
.map_err(map_db_error)?;
let mut subtotal_cents: i64 = 0;
let items: Vec<SaleItemDto> = item_rows.into_iter().map(|r| {
let q = r.quantity_i64;
let line = r.unit_price_cents * q;
subtotal_cents += line;
SaleItemDto {
product_id: r.product_id,
quantity: q,
unit_price_cents: r.unit_price_cents,
line_total_cents: line,
}
}).collect();
#[derive(sqlx::FromRow)]
struct T { tax_id: i64, rate_bp: i32, base_cents: i64, tax_cents: i64 }
let tax_rows: Vec<T> = sqlx::query_as::<_, T>(
r#"
SELECT
tax_id::bigint AS tax_id,
ROUND(rate_pct*100)::int AS rate_bp,
COALESCE(ROUND(base_amount*100)::bigint,0) AS base_cents,
COALESCE(ROUND(tax_amount*100)::bigint,0) AS tax_cents
FROM pos.sale_tax
WHERE sale_id=$1
ORDER BY tax_id
"#,
)
.bind(sale_id)
.fetch_all(pool)
.await
.map_err(map_db_error)?;
let taxes: Vec<SaleTaxDto> = tax_rows
.into_iter()
.map(|r| SaleTaxDto {
tax_id: r.tax_id,
rate_bp: r.rate_bp,
base_cents: r.base_cents,
tax_cents: r.tax_cents,
})
.collect();
let tax_cents: i64 = taxes.iter().map(|t| t.tax_cents).sum();
let total_cents = subtotal_cents + tax_cents;
#[derive(sqlx::FromRow)]
struct PayRow {
method_code: String,
amount_cents: i64,
}
let pay_rows: Vec<PayRow> = sqlx::query_as(
r#"
SELECT
pm.code AS method_code,
COALESCE(ROUND(sp.amount*100)::bigint,0) AS amount_cents
FROM pos.sale_payment sp
JOIN pos.payment_method pm ON pm.id = sp.payment_method_id
WHERE sp.sale_id=$1
ORDER BY sp.id
"#,
)
.bind(sale_id)
.fetch_all(pool)
.await
.map_err(map_db_error)?;
let payments: Vec<SalePaymentDto> = pay_rows
.into_iter()
.map(|r| SalePaymentDto {
method_code: r.method_code,
amount_cents: r.amount_cents,
})
.collect();
let paid_cents: i64 = payments.iter().map(|p| p.amount_cents).sum();
// balance = lo que falta por pagar (nunca negativo)
let balance_cents = (total_cents - paid_cents).max(0);
// change = vuelto (nunca negativo)
let change_cents = (paid_cents - total_cents).max(0);
Ok(SaleDto {
id: h.id,
invoice_number: h.invoice_number,
status_code: h.status_code,
cashier_user_id: h.cashier_user_id,
sold_at: h.sold_at,
created_at: h.created_at,
updated_at: h.updated_at,
subtotal_cents,
tax_cents,
total_cents,
paid_cents,
balance_cents,
change_cents,
items,
taxes,
payments,
})
}
pub async fn cancel_sale(
pool: &PgPool,
company_id: i64,
admin_user_id: i64,
sale_id: i64,
) -> Result<(), SalesError> {
let mut tx = pool.begin().await.map_err(map_db_error)?;
let status_cancelled_id = get_id_by_code(&mut tx, "pos.sale_status", "CANCELLED").await?;
let in_void_type_id = get_id_by_code(&mut tx, "pos.inventory_movement_type", "IN_VOID").await?;
// validar sale existe y no esté cancelada
let status_code: Option<String> = sqlx::query_scalar(
r#"
SELECT st.code
FROM pos.sale s
JOIN pos.sale_status st ON st.id = s.status_id
WHERE s.company_id=$1 AND s.id=$2
LIMIT 1
"#,
)
.bind(company_id)
.bind(sale_id)
.fetch_optional(&mut *tx)
.await
.map_err(map_db_error)?;
let status_code = status_code.ok_or(SalesError::NotFound)?;
if status_code == "CANCELLED" {
let _ = tx.rollback().await;
return Err(SalesError::BadRequestDetail("La venta ya está cancelada".to_string()));
}
// warehouse usado: lo tomamos del movimiento OUT_SALE (reference_table='sale')
let warehouse_id: Option<i64> = sqlx::query_scalar(
r#"
SELECT warehouse_id::bigint AS warehouse_id
FROM pos.inventory_movement
WHERE company_id=$1 AND reference_table='sale' AND reference_id=$2
ORDER BY id
LIMIT 1
"#,
)
.bind(company_id)
.bind(sale_id)
.fetch_optional(&mut *tx)
.await
.map_err(map_db_error)?;
let warehouse_id = warehouse_id.ok_or_else(|| SalesError::BadRequestDetail(
"No hay movimientos OUT_SALE asociados; no puedo determinar warehouse".to_string()
))?;
// items (reponer stock) desde sale_item
#[derive(sqlx::FromRow)]
struct I { product_id: i64, qty_i64: i64 }
let items: Vec<I> = sqlx::query_as(
r#"SELECT product_id::bigint AS product_id, ROUND(quantity)::bigint AS qty_i64
FROM pos.sale_item WHERE sale_id=$1"#,
)
.bind(sale_id)
.fetch_all(&mut *tx)
.await
.map_err(map_db_error)?;
for it in items {
let qty = it.qty_i64;
stock_increase(&mut tx, warehouse_id, it.product_id, qty).await?;
// kardex IN_VOID
sqlx::query(
r#"
INSERT INTO pos.inventory_movement(
company_id, warehouse_id, product_id, movement_type_id, qty,
reference_table, reference_id, notes, created_by
)
VALUES ($1,$2,$3,$4,($5::numeric),'sale',$6,'Cancelación de venta',$7)
"#,
)
.bind(company_id)
.bind(warehouse_id)
.bind(it.product_id)
.bind(in_void_type_id)
.bind(qty)
.bind(sale_id)
.bind(admin_user_id)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
}
// actualizar sale a CANCELLED
sqlx::query(
r#"
UPDATE pos.sale
SET status_id=$3,
updated_at=now()
WHERE company_id=$1 AND id=$2
"#,
)
.bind(company_id)
.bind(sale_id)
.bind(status_cancelled_id)
.execute(&mut *tx)
.await
.map_err(map_db_error)?;
tx.commit().await.map_err(map_db_error)?;
Ok(())
}