Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cloud/lambda-v1-handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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];
});
Expand Down
50 changes: 47 additions & 3 deletions electron/database/repositories/cashRegister.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -145,9 +186,12 @@ export function addCashMovement(data: {
type: 'expense' | 'withdrawal' | 'deposit';
amount: number;
description?: string | null;
movement_date?: string;
idempotency_key?: string;
}): IdempotentResult<CashMovement> {
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;
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions electron/lib/cloudApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ export class CloudApi {
type: 'expense' | 'withdrawal' | 'deposit';
amount: number;
description?: string | null;
movement_date?: string;
idempotency_key?: string;
}): Promise<CashMovement> {
return this.request<CashMovement>('POST', '/v1/cash-register/movements', data)
Expand All @@ -751,6 +752,7 @@ export class CloudApi {
type?: 'expense' | 'withdrawal' | 'deposit';
amount?: number;
description?: string | null;
movement_date?: string;
}): Promise<CashMovement> {
return this.request<CashMovement>('PUT', `/v1/cash-register/movements/${id}`, data)
.then(normalizeCashMovementRow);
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useCashRegister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function useCashRegister() {
type: 'expense' | 'withdrawal' | 'deposit';
amount: number;
description?: string | null;
movement_date?: string;
}): Promise<CashMovement> => {
try {
const movement = await window.electronAPI.cashRegister.addMovement(data);
Expand Down
161 changes: 130 additions & 31 deletions src/lib/formatters.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<string, string> = {};
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 {
Expand Down
Loading
Loading