diff --git a/cloud/lambda-v1-handler.mjs b/cloud/lambda-v1-handler.mjs index fe867d2..acaaddb 100644 --- a/cloud/lambda-v1-handler.mjs +++ b/cloud/lambda-v1-handler.mjs @@ -445,6 +445,7 @@ const handler = async (event) => { } const amount = requirePositiveNumber(body.amount, "amount"); const idempotencyKey = body.idempotency_key?.trim() || null; + const movementCreatedAt = resolveCreatedAtFromDate(body.movement_date, "movement_date"); const data = await withTx(async (client) => { if (idempotencyKey) { const existing = await client.query( @@ -456,10 +457,10 @@ const handler = async (event) => { } } const ins = await client.query( - `INSERT INTO cash_movements (cash_register_id, type, amount, description, idempotency_key) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO cash_movements (cash_register_id, type, amount, description, idempotency_key, created_at) + VALUES ($1, $2, $3, $4, $5, COALESCE($6::timestamp, NOW())) RETURNING *`, - [cashRegisterId, body.type, amount, body.description ?? null, idempotencyKey] + [cashRegisterId, body.type, amount, body.description ?? null, idempotencyKey, movementCreatedAt] ); return ins.rows[0]; }); diff --git a/electron/database/repositories/cashRegister.ts b/electron/database/repositories/cashRegister.ts index 9596672..af43b05 100644 --- a/electron/database/repositories/cashRegister.ts +++ b/electron/database/repositories/cashRegister.ts @@ -1,4 +1,6 @@ import { getDatabase } from '../connection'; +import { getSetting } from './settings'; +import { getBusinessNowDateTime, getBusinessTodayDate, resolveBusinessTimeZone } from '../../lib/time'; import type { CashRegisterPeriod, CashMovement, CreditPaymentListItem, SaleListItem, PaginatedQuery, PaginatedResponse, SortSpec, IdempotentResult } from '../../../src/types/database'; import { sanitizePagination, calcLimitOffset, buildLikePattern, isValidDateFilter, isValidStatus, isValidIdempotencyKey } from '../../lib/queryHelpers'; import { incrementVersion } from '../../lib/dataVersions'; @@ -33,6 +35,45 @@ function toSafeNumber(value: unknown): number { return 0; } +function isValidDateString(dateValue: string): boolean { + const [year, month, day] = dateValue.split('-').map(Number); + const selectedDate = new Date(year, month - 1, day, 0, 0, 0, 0); + + return ( + !Number.isNaN(selectedDate.getTime()) + && selectedDate.getFullYear() === year + && selectedDate.getMonth() === month - 1 + && selectedDate.getDate() === day + ); +} + +function resolveCashMovementCreatedAt(movementDate: string | undefined, businessTimeZone: string): string { + const todayDate = getBusinessTodayDate(businessTimeZone); + + if (!movementDate || movementDate.trim() === '') { + return getBusinessNowDateTime(businessTimeZone); + } + + const trimmedDate = movementDate.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmedDate)) { + throw new Error('La fecha del movimiento no tiene un formato valido'); + } + + if (!isValidDateString(trimmedDate)) { + throw new Error('La fecha del movimiento no es valida'); + } + + if (trimmedDate > todayDate) { + throw new Error('No se permiten fechas futuras para el movimiento'); + } + + if (trimmedDate === todayDate) { + return getBusinessNowDateTime(businessTimeZone); + } + + return `${trimmedDate} 00:00:00`; +} + const DEFAULT_SORT: SortSpec = { field: 'created_at', direction: 'DESC' }; const ALLOWED_SALE_TYPES = ['cash', 'credit'] as const; const ALLOWED_MOVEMENT_TYPES = ['expense', 'withdrawal', 'deposit'] as const; @@ -145,9 +186,12 @@ export function addCashMovement(data: { type: 'expense' | 'withdrawal' | 'deposit'; amount: number; description?: string | null; + movement_date?: string; idempotency_key?: string; }): IdempotentResult { const db = getDatabase(); + const businessTimeZone = resolveBusinessTimeZone(getSetting('business_timezone')); + const createdAt = resolveCashMovementCreatedAt(data.movement_date, businessTimeZone); // Idempotency check const idempotencyKey = isValidIdempotencyKey(data.idempotency_key) ? data.idempotency_key : null; @@ -162,9 +206,9 @@ export function addCashMovement(data: { } const result = db.prepare(` - INSERT INTO cash_movements (cash_register_id, type, amount, description, idempotency_key) - VALUES (?, ?, ?, ?, ?) - `).run(data.cash_register_id, data.type, data.amount, data.description ?? null, idempotencyKey); + INSERT INTO cash_movements (cash_register_id, type, amount, description, idempotency_key, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(data.cash_register_id, data.type, data.amount, data.description ?? null, idempotencyKey, createdAt); const movement = db.prepare('SELECT * FROM cash_movements WHERE id = ?').get( Number(result.lastInsertRowid) diff --git a/electron/lib/cloudApi.ts b/electron/lib/cloudApi.ts index b59a87d..8fcd704 100644 --- a/electron/lib/cloudApi.ts +++ b/electron/lib/cloudApi.ts @@ -736,6 +736,7 @@ export class CloudApi { type: 'expense' | 'withdrawal' | 'deposit'; amount: number; description?: string | null; + movement_date?: string; idempotency_key?: string; }): Promise { return this.request('POST', '/v1/cash-register/movements', data) @@ -751,6 +752,7 @@ export class CloudApi { type?: 'expense' | 'withdrawal' | 'deposit'; amount?: number; description?: string | null; + movement_date?: string; }): Promise { return this.request('PUT', `/v1/cash-register/movements/${id}`, data) .then(normalizeCashMovementRow); diff --git a/electron/preload.ts b/electron/preload.ts index 504441f..8d5f395 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -157,6 +157,7 @@ const electronAPI = { type: 'expense' | 'withdrawal' | 'deposit'; amount: number; description?: string | null; + movement_date?: string; idempotency_key?: string; }) => ipcRenderer.invoke(IPC_CHANNELS.CASH_REGISTER_ADD_MOVEMENT, data), updateMovement: (id: number, data: { diff --git a/package.json b/package.json index 418c83c..58368a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "store-internal", "private": true, - "version": "0.8.13", + "version": "0.8.14", "license": "Apache-2.0", "type": "module", "main": "dist-electron/main.js", diff --git a/src/hooks/useCashRegister.ts b/src/hooks/useCashRegister.ts index d12e15d..a453dcd 100644 --- a/src/hooks/useCashRegister.ts +++ b/src/hooks/useCashRegister.ts @@ -131,6 +131,7 @@ export function useCashRegister() { type: 'expense' | 'withdrawal' | 'deposit'; amount: number; description?: string | null; + movement_date?: string; }): Promise => { try { const movement = await window.electronAPI.cashRegister.addMovement(data); diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 50de153..1512268 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -1,5 +1,7 @@ // Formatting utilities for display +export const DEFAULT_BUSINESS_TIMEZONE = 'America/Mexico_City'; + export function formatCurrency(amount: number): string { return new Intl.NumberFormat('es-MX', { style: 'currency', @@ -9,60 +11,157 @@ export function formatCurrency(amount: number): string { }).format(amount); } -// Parse stored dates into a UTC Date preserving the original calendar day. -// DB values are usually stored as "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS". -// Cloud responses can also come back as ISO-8601 strings with "T" and "Z". -// We handle both without letting the local timezone shift the visible date. -function parseDateStringAsUTC(dateString: string | null | undefined): Date | null { - if (!dateString || dateString.trim() === '') { +function parseDateOnlyParts(dateString: string): { year: number; month: number; day: number } | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { return null; } - const trimmed = dateString.trim(); + const [year, month, day] = dateString.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + + if ( + Number.isNaN(date.getTime()) + || date.getUTCFullYear() !== year + || date.getUTCMonth() !== month - 1 + || date.getUTCDate() !== day + ) { + return null; + } - if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { - const [year, month, day] = trimmed.split('-').map(Number); - const date = new Date(Date.UTC(year, month - 1, day)); - return Number.isNaN(date.getTime()) ? null : date; + return { year, month, day }; +} + +function parseLocalDateTimeParts(dateString: string): { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} | null { + const match = dateString.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?$/); + if (!match) { + return null; } - if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/.test(trimmed)) { - const date = new Date(trimmed); - return Number.isNaN(date.getTime()) ? null : date; + const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr] = match; + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); + const hour = Number(hourStr); + const minute = Number(minuteStr); + const second = Number(secondStr); + const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + + if ( + Number.isNaN(date.getTime()) + || date.getUTCFullYear() !== year + || date.getUTCMonth() !== month - 1 + || date.getUTCDate() !== day + || date.getUTCHours() !== hour + || date.getUTCMinutes() !== minute + || date.getUTCSeconds() !== second + ) { + return null; } - const parsed = new Date(trimmed); - return Number.isNaN(parsed.getTime()) ? null : parsed; + return { year, month, day, hour, minute, second }; } -export function formatDate(dateString: string | null | undefined): string { - const date = parseDateStringAsUTC(dateString); - if (!date) { - return '-'; +function isZonedDateTimeString(dateString: string): boolean { + return /[zZ]|[+-]\d{2}:?\d{2}$/.test(dateString); +} + +function parseDateString(dateString: string | null | undefined): { + kind: 'date' | 'localDateTime' | 'zonedDateTime'; + date: Date; +} | null { + if (!dateString || dateString.trim() === '') { + return null; + } + + const trimmed = dateString.trim(); + + const dateOnlyParts = parseDateOnlyParts(trimmed); + if (dateOnlyParts) { + return { kind: 'date', date: new Date(Date.UTC(dateOnlyParts.year, dateOnlyParts.month - 1, dateOnlyParts.day, 0, 0, 0)) }; + } + + const localDateTimeParts = parseLocalDateTimeParts(trimmed); + if (localDateTimeParts) { + return { + kind: 'localDateTime', + date: new Date(Date.UTC( + localDateTimeParts.year, + localDateTimeParts.month - 1, + localDateTimeParts.day, + localDateTimeParts.hour, + localDateTimeParts.minute, + localDateTimeParts.second, + )), + }; + } + + if (isZonedDateTimeString(trimmed)) { + const parsed = new Date(trimmed); + return Number.isNaN(parsed.getTime()) ? null : { kind: 'zonedDateTime', date: parsed }; } + return null; +} + +function formatPartsInTimeZone(date: Date, timeZone: string, includeTime: boolean): string { return new Intl.DateTimeFormat('es-MX', { year: 'numeric', month: 'short', day: 'numeric', - timeZone: 'UTC', + ...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {}), + timeZone, }).format(date); } +export function getBusinessTodayDate(timeZone: string = DEFAULT_BUSINESS_TIMEZONE): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date()); + + const values: Record = {}; + for (const part of parts) { + if (part.type === 'year' || part.type === 'month' || part.type === 'day') { + values[part.type] = part.value; + } + } + + return `${values.year}-${values.month}-${values.day}`; +} + +export function formatDate(dateString: string | null | undefined): string { + const parsed = parseDateString(dateString); + if (!parsed) { + return '-'; + } + + if (parsed.kind === 'zonedDateTime') { + return formatPartsInTimeZone(parsed.date, DEFAULT_BUSINESS_TIMEZONE, false); + } + + return formatPartsInTimeZone(parsed.date, 'UTC', false); +} + export function formatDateTime(dateString: string | null | undefined): string { - const date = parseDateStringAsUTC(dateString); - if (!date) { + const parsed = parseDateString(dateString); + if (!parsed) { return '-'; } - return new Intl.DateTimeFormat('es-MX', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZone: 'UTC', - }).format(date); + if (parsed.kind === 'zonedDateTime') { + return formatPartsInTimeZone(parsed.date, DEFAULT_BUSINESS_TIMEZONE, true); + } + + return formatPartsInTimeZone(parsed.date, 'UTC', true); } export function formatInteger(value: number | string | null | undefined): string { diff --git a/src/pages/CashRegisterPage.tsx b/src/pages/CashRegisterPage.tsx index 11130b7..d03dce8 100644 --- a/src/pages/CashRegisterPage.tsx +++ b/src/pages/CashRegisterPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { ArrowLeft, Plus, Lock, DollarSign, Edit2, Trash2 } from 'lucide-react'; import { useCashRegister } from '../hooks/useCashRegister'; -import { formatCurrency, formatDate, formatDateTime } from '../lib/formatters'; +import { formatCurrency, formatDate, formatDateTime, getBusinessTodayDate } from '../lib/formatters'; import type { CashRegisterPeriod, CashMovement, CreditPaymentListItem, SaleListItem, PaginatedResponse } from '../types'; import styles from './CashRegisterPage.module.css'; @@ -51,16 +51,14 @@ export function CashRegisterPage() { // Open period form state const [periodName, setPeriodName] = useState(''); const [openingCash, setOpeningCash] = useState(''); - const [startDate, setStartDate] = useState(() => { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - }); + const [startDate, setStartDate] = useState(() => getBusinessTodayDate()); const [openError, setOpenError] = useState(null); // Movement form state const [movementType, setMovementType] = useState<'expense' | 'withdrawal' | 'deposit'>('expense'); const [movementAmount, setMovementAmount] = useState(''); const [movementDescription, setMovementDescription] = useState(''); + const [movementDate, setMovementDate] = useState(() => getBusinessTodayDate()); const [movementError, setMovementError] = useState(null); // Edit movement modal state @@ -72,10 +70,7 @@ export function CashRegisterPage() { // Close period state const [closingCash, setClosingCash] = useState(''); - const [closingDate, setClosingDate] = useState(() => { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - }); + const [closingDate, setClosingDate] = useState(() => getBusinessTodayDate()); const [showCloseConfirm, setShowCloseConfirm] = useState(false); // Detail view for historical periods @@ -467,6 +462,52 @@ export function CashRegisterPage() { setTimeout(() => setNotification(null), 3000); } + function getDateOnly(value: string | null | undefined): string { + if (!value) { + return ''; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value.slice(0, 10); + } + + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Mexico_City', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(parsed); + } + + function parseDateOnly(value: string): number | null { + const trimmed = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return null; + } + + const [year, month, day] = trimmed.split('-').map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + if ( + Number.isNaN(parsed.getTime()) + || parsed.getUTCFullYear() !== year + || parsed.getUTCMonth() !== month - 1 + || parsed.getUTCDate() !== day + ) { + return null; + } + + return parsed.getTime(); + } + + useEffect(() => { + setMovementDate(getBusinessTodayDate()); + }, [currentPeriod?.id]); + // Summary for the current period based on live movements const movementsSummary = useMemo(() => { const totalExpenses = movements @@ -521,12 +562,39 @@ export function CashRegisterPage() { async function handleAddMovement() { setMovementError(null); const amount = parseFloat(movementAmount); + const todayDate = getBusinessTodayDate(); if (isNaN(amount) || amount <= 0) { setMovementError('Ingrese un monto valido mayor a 0'); return; } if (!currentPeriod) return; + if (!movementDate) { + setMovementError('Seleccione la fecha del movimiento'); + return; + } + const periodStartDate = getDateOnly(currentPeriod.start_date); + const movementTime = parseDateOnly(movementDate); + const periodStartTime = parseDateOnly(periodStartDate); + + if (movementTime === null) { + setMovementError('La fecha del movimiento no es valida'); + return; + } + + if (periodStartTime === null) { + setMovementError('La fecha de inicio del periodo no es valida'); + return; + } + + if (movementTime < periodStartTime) { + setMovementError('La fecha del movimiento no puede ser menor a la fecha de inicio del periodo'); + return; + } + if (movementDate > todayDate) { + setMovementError('La fecha del movimiento no puede ser futura'); + return; + } try { setSubmitting(true); @@ -535,9 +603,11 @@ export function CashRegisterPage() { type: movementType, amount, description: movementDescription.trim() || null, + movement_date: movementDate, }); setMovementAmount(''); setMovementDescription(''); + setMovementDate(todayDate); showNotification('success', `${MOVEMENT_TYPE_LABELS[movementType]} registrado correctamente`); } catch (err) { const message = err instanceof Error ? err.message : 'Error al registrar movimiento'; @@ -621,7 +691,23 @@ export function CashRegisterPage() { return; } - if (closingDate < currentPeriod.start_date) { + const periodStartDate = getDateOnly(currentPeriod.start_date); + const closingTime = parseDateOnly(closingDate); + const periodStartTime = parseDateOnly(periodStartDate); + + if (closingTime === null) { + showNotification('error', 'La fecha de cierre no es valida'); + setShowCloseConfirm(false); + return; + } + + if (periodStartTime === null) { + showNotification('error', 'La fecha de inicio del periodo no es valida'); + setShowCloseConfirm(false); + return; + } + + if (closingTime < periodStartTime) { showNotification('error', 'La fecha de cierre no puede ser menor a la fecha de inicio del periodo'); setShowCloseConfirm(false); return; @@ -828,6 +914,17 @@ export function CashRegisterPage() { step="0.01" /> +
+ + setMovementDate(e.target.value)} + min={getDateOnly(currentPeriod.start_date)} + max={getBusinessTodayDate()} + /> +
setClosingDate(e.target.value)} - min={currentPeriod.start_date} + min={getDateOnly(currentPeriod.start_date)} />