Crear Gasto Manual
Exportar contenido
Formulario completo para crear gastos manualmente con validación de datos y cálculo automático de IVA.
Componente
Sección titulada «Componente»<ExpenseFormSheet expenseId={null} transaction={null} open={isOpen} onOpenChange={setIsOpen} onSuccess={handleSuccess}/>Características
Sección titulada «Características»- Formulario modal con UI consistente (FormSheet template)
- Campos validados con feedback en tiempo real
- Cálculo automático de IVA y total
- Subida de justificante (imagen o PDF, máx 5MB)
- Selección de proveedor con autocompletado y creación rápida
- Indicador de deducibilidad que muestra % IVA deducible
- Categorías predefinidas alineadas con PGC español
- Detección de bien de inversión (umbral 3.005,06€)
Campos del formulario
Sección titulada «Campos del formulario»| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
name | string | Sí | Concepto del gasto |
amount | number | Sí | Base imponible (sin IVA) |
tax_rate | number | Sí | Tipo de IVA (0, 4, 10, 21%) |
expense_date | date | Sí | Fecha del gasto |
category | select | Sí | Categoría contable |
document_type | select | Sí | Tipo de documento fiscal |
vendor_id | combobox | No | Proveedor vinculado |
vendor_name | string | No | Nombre del proveedor (si no existe) |
vendor_tax_id | string | No | NIF/CIF del proveedor |
invoice_number | string | No | Número de factura |
description | textarea | No | Descripción adicional |
receipt_url | file | No | Archivo justificante |
is_recurring | boolean | No | Marcar como gasto recurrente |
Tipos de documento
Sección titulada «Tipos de documento»type ExpenseDocumentType = | 'invoice' // Factura completa - IVA deducible | 'simplified_invoice' // Ticket/Factura simplificada - IVA no deducible | 'receipt'; // Justificante - IVA no deducibleImpacto fiscal:
- Solo las facturas completas con NIF del receptor permiten deducir el IVA
- Tickets y facturas simplificadas NO permiten deducción (Art. 97 LIVA)
Cálculo de IVA
Sección titulada «Cálculo de IVA»El formulario calcula automáticamente:
const baseAmount = parseFloat(formData.amount);const taxRate = parseFloat(formData.tax_rate);const taxAmount = (baseAmount * taxRate) / 100;const totalAmount = baseAmount + taxAmount;Deducibilidad automática
Sección titulada «Deducibilidad automática»import { calculateDeductibility, isInvestmentGood } from '@/features/expenses/utils/deductibility';
const deductibility = calculateDeductibility({ documentType: formData.document_type, vendorTaxId: formData.vendor_tax_id, taxAmount, totalAmount, category: formData.category, isInvestment: isInvestmentGood(totalAmount, formData.category),});
// Resultado:// {// isDeductible: true/false,// deductiblePercentage: 0-100,// deductibleTaxAmount: number,// nonDeductibleReason?: string,// explanation: string// }Validación con Zod
Sección titulada «Validación con Zod»import { expenseSchema } from '@/schemas/validation';
expenseSchema.parse({ name: formData.name, amount: parseFloat(formData.amount), category: formData.category, expense_date: format(formData.expense_date, 'yyyy-MM-dd'), tax_rate: parseFloat(formData.tax_rate),});Almacenamiento de justificantes
Sección titulada «Almacenamiento de justificantes»Los archivos se suben a Supabase Storage en el bucket expense-receipts:
const fileName = `${expenseId}-${Date.now()}.${fileExt}`;const filePath = `${user.id}/${fileName}`;
await supabase.storage .from('expense-receipts') .upload(filePath, selectedFile);Restricciones:
- Tamaño máximo: 5MB
- Formatos permitidos: JPG, PNG, PDF
- Estructura:
{user_id}/{expense_id}-{timestamp}.{ext}
Estados del gasto
Sección titulada «Estados del gasto»- recorded: Gasto registrado, pendiente de verificación
- verified: Gasto verificado por el usuario o asesor
Código de ejemplo completo
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault();
// Validación const validationData = { name: formData.name.trim(), amount: parseFloat(formData.amount), category: formData.category, expense_date: format(formData.expense_date, 'yyyy-MM-dd'), tax_rate: parseFloat(formData.tax_rate), };
expenseSchema.parse(validationData);
// Cálculos const taxAmount = (parseFloat(formData.amount) * parseFloat(formData.tax_rate)) / 100; const totalAmount = parseFloat(formData.amount) + taxAmount;
// Deducibilidad const deductibility = calculateDeductibility({ documentType: formData.document_type, vendorTaxId: formData.vendor_tax_id, taxAmount, totalAmount, category: formData.category, isInvestment: isInvestmentGood(totalAmount, formData.category), });
// Datos del gasto const expenseData = { name: formData.name.trim(), amount: parseFloat(formData.amount), tax_rate: parseFloat(formData.tax_rate), tax_amount: taxAmount, total_amount: totalAmount, expense_date: formData.expense_date.toISOString(), category: formData.category, vendor_id: formData.vendor_id, document_type: formData.document_type, is_deductible: deductibility.isDeductible, deductible_percentage: deductibility.deductiblePercentage, deductible_tax_amount: deductibility.deductibleTaxAmount, non_deductible_reason: deductibility.nonDeductibleReason || null, is_investment: isInvestmentGood(totalAmount, formData.category), user_id: user.id, business_id: currentBusinessId, status: 'recorded', };
// Inserción const { data: newExpense, error } = await supabase .from('expenses') .insert([expenseData]) .select() .single();
if (error) throw error;
// Subir justificante si existe if (selectedFile) { const receiptUrl = await uploadReceipt(newExpense.id); if (receiptUrl) { await supabase .from('expenses') .update({ receipt_url: receiptUrl }) .eq('id', newExpense.id); } }
onSuccess?.();};