feat(facturacion): fecha de emisión personalizable para I, E, T
- Frontend: input datetime-local visible solo para tipos I, E, T (no P). Default al día actual a las 12:00. Se resetea al cambiar tipo. - Frontend: validación en handleSubmit: fecha ≤ ahora y ≥ ahora-72h - Backend controller: validación idéntica antes de consumir timbre - Backend servicios: pasa campo 'date' al payload de Facturapi cuando viene 'fechaEmision' en el body - Build y deploy exitosos
This commit is contained in:
@@ -138,6 +138,17 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Validar fecha de emisión (solo I, E, T) ──
|
||||||
|
const tipo = req.body.type || 'I';
|
||||||
|
if (tipo !== 'P' && req.body.fechaEmision) {
|
||||||
|
const fecha = new Date(req.body.fechaEmision);
|
||||||
|
const now = new Date();
|
||||||
|
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
|
||||||
|
if (isNaN(fecha.getTime()) || fecha > now || fecha < minDate) {
|
||||||
|
throw new AppError(400, 'La fecha de emisión debe estar entre 72 horas en el pasado y el momento actual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
||||||
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ export async function createInvoiceContribuyente(
|
|||||||
|
|
||||||
if (data.series) invoicePayload.series = data.series;
|
if (data.series) invoicePayload.series = data.series;
|
||||||
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
||||||
|
if (data.fechaEmision) invoicePayload.date = data.fechaEmision;
|
||||||
|
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
|
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ export async function createInvoice(
|
|||||||
|
|
||||||
if (data.series) invoiceData.series = data.series;
|
if (data.series) invoiceData.series = data.series;
|
||||||
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
||||||
|
if ((data as any).fechaEmision) invoiceData.date = (data as any).fechaEmision;
|
||||||
|
|
||||||
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
|
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ export default function FacturacionPage() {
|
|||||||
const [serie, setSerie] = useState('');
|
const [serie, setSerie] = useState('');
|
||||||
const [folio, setFolio] = useState('');
|
const [folio, setFolio] = useState('');
|
||||||
const [condiciones, setCondiciones] = useState('');
|
const [condiciones, setCondiciones] = useState('');
|
||||||
|
const [fechaEmision, setFechaEmision] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(12, 0, 0, 0);
|
||||||
|
return d.toISOString().slice(0, 16);
|
||||||
|
});
|
||||||
|
|
||||||
// Conceptos
|
// Conceptos
|
||||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
||||||
@@ -535,6 +540,10 @@ export default function FacturacionPage() {
|
|||||||
// Resetear conceptos con unidad default según tipo
|
// Resetear conceptos con unidad default según tipo
|
||||||
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
|
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
|
||||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
||||||
|
// Resetear fecha de emisión al día actual (12:00)
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(12, 0, 0, 0);
|
||||||
|
setFechaEmision(d.toISOString().slice(0, 16));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unidades de servicio que no aplican para Traslado
|
// Unidades de servicio que no aplican para Traslado
|
||||||
@@ -651,6 +660,20 @@ export default function FacturacionPage() {
|
|||||||
if (folio) data.folioNumber = parseInt(folio) || undefined;
|
if (folio) data.folioNumber = parseInt(folio) || undefined;
|
||||||
if (condiciones) data.conditions = condiciones;
|
if (condiciones) data.conditions = condiciones;
|
||||||
|
|
||||||
|
// Validar fecha de emisión para I, E, T
|
||||||
|
if (tipoComprobante !== 'P' && fechaEmision) {
|
||||||
|
const now = new Date();
|
||||||
|
const selected = new Date(fechaEmision);
|
||||||
|
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
|
||||||
|
if (selected > now) {
|
||||||
|
alert('La fecha de emisión no puede ser a futuro'); return;
|
||||||
|
}
|
||||||
|
if (selected < minDate) {
|
||||||
|
alert('La fecha de emisión no puede ser mayor a 72 horas en el pasado'); return;
|
||||||
|
}
|
||||||
|
data.fechaEmision = selected.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
if (config.needsConceptos) {
|
if (config.needsConceptos) {
|
||||||
if (conceptos.some(c => !c.description || !c.productKey)) {
|
if (conceptos.some(c => !c.description || !c.productKey)) {
|
||||||
alert('Completa todos los conceptos'); return;
|
alert('Completa todos los conceptos'); return;
|
||||||
@@ -1077,6 +1100,17 @@ export default function FacturacionPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{tipoComprobante !== 'P' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fecha de Emisión</Label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={fechaEmision}
|
||||||
|
onChange={e => setFechaEmision(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Máximo 72 horas en el pasado. No se permiten fechas a futuro.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Serie (opcional)</Label>
|
<Label>Serie (opcional)</Label>
|
||||||
<Input value={serie} onChange={e => setSerie(e.target.value.toUpperCase())} placeholder="P" maxLength={10} />
|
<Input value={serie} onChange={e => setSerie(e.target.value.toUpperCase())} placeholder="P" maxLength={10} />
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface InvoiceData {
|
|||||||
series?: string;
|
series?: string;
|
||||||
folioNumber?: number;
|
folioNumber?: number;
|
||||||
conditions?: string;
|
conditions?: string;
|
||||||
|
fechaEmision?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceResult {
|
export interface InvoiceResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user