Ir al contenido

Crear Gasto Manual

Formulario completo para crear gastos manualmente con validación de datos y cálculo automático de IVA.

src/features/expenses/components/ExpenseFormSheet.tsx
<ExpenseFormSheet
expenseId={null}
transaction={null}
open={isOpen}
onOpenChange={setIsOpen}
onSuccess={handleSuccess}
/>
  • 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€)
CampoTipoObligatorioDescripción
namestringConcepto del gasto
amountnumberBase imponible (sin IVA)
tax_ratenumberTipo de IVA (0, 4, 10, 21%)
expense_datedateFecha del gasto
categoryselectCategoría contable
document_typeselectTipo de documento fiscal
vendor_idcomboboxNoProveedor vinculado
vendor_namestringNoNombre del proveedor (si no existe)
vendor_tax_idstringNoNIF/CIF del proveedor
invoice_numberstringNoNúmero de factura
descriptiontextareaNoDescripción adicional
receipt_urlfileNoArchivo justificante
is_recurringbooleanNoMarcar como gasto recurrente
type ExpenseDocumentType =
| 'invoice' // Factura completa - IVA deducible
| 'simplified_invoice' // Ticket/Factura simplificada - IVA no deducible
| 'receipt'; // Justificante - IVA no deducible

Impacto fiscal:

  • Solo las facturas completas con NIF del receptor permiten deducir el IVA
  • Tickets y facturas simplificadas NO permiten deducción (Art. 97 LIVA)

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;
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
// }
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),
});

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}
  • 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?.();
};