F-201 F-202 F-203: POS cash close report + email + PIN admin
F-201: GET /pos/reports/cash-close/:id with financial summary, sales by state, payments breakdown, items sold. Extended /pos/sessions/:id. F-202: Cash close email sent on session close to smtpReportEmail (best-effort). smtpReportEmail field added to admin SMTP settings. F-203: Admin POS terminal config: selfpayMode, closeSessionRequiresPin, closeSessionPin (4-6 digits) with dedicated settings section.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,33 @@ export default function PosAdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// F-203: save selfpay + PIN settings via PATCH /pos/admin/terminals/:id
|
||||||
|
const saveSecuritySettings = async () => {
|
||||||
|
if (!configuring) return;
|
||||||
|
setSavingSecurity(true);
|
||||||
|
setSecurityMessage('');
|
||||||
|
try {
|
||||||
|
const settings: Record<string, unknown> = {
|
||||||
|
selfpayMode,
|
||||||
|
closeSessionRequiresPin: closePinRequired,
|
||||||
|
};
|
||||||
|
if (closePinValue) settings.closeSessionPin = closePinValue;
|
||||||
|
await api.patch(`/api/pos/admin/terminals/${configuring.id}`, { settings });
|
||||||
|
setSecurityMessage('Configuración de cierre guardada');
|
||||||
|
setTerminals(prev =>
|
||||||
|
prev.map(t =>
|
||||||
|
t.id === configuring.id
|
||||||
|
? { ...t, settings: { ...t.settings, ...settings } }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setSecurityMessage('Error al guardar: ' + (err instanceof Error ? err.message : 'Error'));
|
||||||
|
} finally {
|
||||||
|
setSavingSecurity(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createPaymentMethod = async (event: React.FormEvent) => {
|
const createPaymentMethod = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSavingMethod(true);
|
setSavingMethod(true);
|
||||||
@@ -600,16 +627,55 @@ export default function PosAdminPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex items-center gap-4">
|
{/* F-203: cash close security settings */}
|
||||||
<button
|
<div className="mt-6 border-t pt-4">
|
||||||
type="button"
|
<h3 className="mb-3 text-sm font-bold text-gray-900">Seguridad de cierre de caja</h3>
|
||||||
onClick={() => void saveTouchConfig()}
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
disabled={savingTouch}
|
<label className="flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium">
|
||||||
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
{savingTouch ? 'Guardando…' : 'Guardar terminal'}
|
checked={selfpayMode}
|
||||||
</button>
|
onChange={(e) => setSelfpayMode(e.target.checked)}
|
||||||
{touchMessage && <p className="text-sm">{touchMessage}</p>}
|
className="h-5 w-5 accent-[#2D6A4F]"
|
||||||
|
/>
|
||||||
|
Modo autopago (oculta botón de cierre)
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={closePinRequired}
|
||||||
|
onChange={(e) => setClosePinRequired(e.target.checked)}
|
||||||
|
className="h-5 w-5 accent-[#2D6A4F]"
|
||||||
|
/>
|
||||||
|
Requerir PIN para cerrar caja
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
PIN de cajero {closePinRequired && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={closePinValue}
|
||||||
|
onChange={(e) => setClosePinValue(e.target.value)}
|
||||||
|
placeholder="4-6 dígitos"
|
||||||
|
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm focus:border-[#2D6A4F] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Este PIN se requiere para autorizar el cierre de caja.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveSecuritySettings()}
|
||||||
|
disabled={savingSecurity}
|
||||||
|
className="rounded-xl bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingSecurity ? 'Guardando…' : 'Guardar seguridad'}
|
||||||
|
</button>
|
||||||
|
{securityMessage && <p className="text-sm">{securityMessage}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -817,7 +883,7 @@ function Field({
|
|||||||
{label}
|
{label}
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value ?? ""}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
required={required}
|
required={required}
|
||||||
min={min}
|
min={min}
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ export default function SettingsPage() {
|
|||||||
{field('smtpUser', 'Usuario / cuenta de correo', { type: 'email', placeholder: 'info@mercadodevida.es' })}
|
{field('smtpUser', 'Usuario / cuenta de correo', { type: 'email', placeholder: 'info@mercadodevida.es' })}
|
||||||
{field('smtpPass', 'Contraseña SMTP', { type: 'password', placeholder: form?.smtpPassConfigured ? 'Contraseña configurada (escribe para reemplazar)' : 'Contraseña del buzón' })}
|
{field('smtpPass', 'Contraseña SMTP', { type: 'password', placeholder: form?.smtpPassConfigured ? 'Contraseña configurada (escribe para reemplazar)' : 'Contraseña del buzón' })}
|
||||||
{field('smtpFrom', 'Remitente', { type: 'email', placeholder: 'info@mercadodevida.es' })}
|
{field('smtpFrom', 'Remitente', { type: 'email', placeholder: 'info@mercadodevida.es' })}
|
||||||
|
<div className="border-t border-gray-200 pt-4 mt-2">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-gray-800">Reportes automáticos</h3>
|
||||||
|
{field('smtpReportEmail', 'Email destino de reportes', { type: 'email', placeholder: 'direccion@ejemplo.com' })}
|
||||||
|
<p className="text-xs text-gray-400 -mt-2">Recibirás el reporte de cierre de caja por email al cerrar cada sesión.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -116,6 +116,46 @@ export default function RegisterPage() {
|
|||||||
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
|
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
// POS-FIX-3/POS-FIX-5: close session
|
||||||
|
const [showCloseSession, setShowCloseSession] = useState(false);
|
||||||
|
const [closingActualCash, setClosingActualCash] = useState('');
|
||||||
|
const [closingPin, setClosingPin] = useState('');
|
||||||
|
const [closingPinError, setClosingPinError] = useState('');
|
||||||
|
const [closingPinStep, setClosingPinStep] = useState(false); // true = PIN entered, show cash dialog
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const [closeError, setCloseError] = useState('');
|
||||||
|
|
||||||
|
// POS-FIX-4: toast notification when product added to cart
|
||||||
|
const [addedToast, setAddedToast] = useState<string | null>(null);
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const showAddedToast = (name: string) => {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
setAddedToast(name);
|
||||||
|
toastTimer = setTimeout(() => setAddedToast(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// POS-FIX-5: verify PIN then close session
|
||||||
|
const handleVerifyPin = async () => {
|
||||||
|
if (!config?.terminal) return;
|
||||||
|
if (!closingPin || closingPin.length < 4) { setClosingPinError('PIN requerido'); return; }
|
||||||
|
setClosingPinError('');
|
||||||
|
setClosingPinStep(true); // proceed to cash amount dialog
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSession = async () => {
|
||||||
|
if (!config?.session) return;
|
||||||
|
const actual = parseInt(closingActualCash, 10);
|
||||||
|
if (isNaN(actual) || actual < 0) { setCloseError('Cantidad inválida'); return; }
|
||||||
|
setClosing(true);
|
||||||
|
setCloseError('');
|
||||||
|
try {
|
||||||
|
await posApi.closeSession(config.session.id, actual, closingPin);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
setCloseError(err instanceof Error ? err.message : 'Error al cerrar');
|
||||||
|
setClosing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
const loadConfig = useCallback(async () => {
|
||||||
setConfigError('');
|
setConfigError('');
|
||||||
@@ -134,8 +174,9 @@ export default function RegisterPage() {
|
|||||||
void loadConfig();
|
void loadConfig();
|
||||||
}, [loadConfig]);
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
// FEAT-200: pending sales by terminalId enables cross-day resumption
|
||||||
const loadPendingSales = useCallback(async () => {
|
const loadPendingSales = useCallback(async () => {
|
||||||
if (!config?.session || config.session.status !== 'OPEN') {
|
if (!config?.terminal || config.session?.status !== 'OPEN') {
|
||||||
setPendingSales([]);
|
setPendingSales([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,7 +184,7 @@ export default function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await posApi.listSales<{ items: PosPendingSale[] }>({
|
const data = await posApi.listSales<{ items: PosPendingSale[] }>({
|
||||||
state: 'PENDING',
|
state: 'PENDING',
|
||||||
sessionId: config.session.id,
|
terminalId: config.terminal.id,
|
||||||
});
|
});
|
||||||
setPendingSales(data.items ?? []);
|
setPendingSales(data.items ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -151,12 +192,22 @@ export default function RegisterPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingPending(false);
|
setLoadingPending(false);
|
||||||
}
|
}
|
||||||
}, [config?.session?.id, config?.session?.status]);
|
}, [config?.terminal?.id, config?.session?.status]);
|
||||||
|
|
||||||
|
// POS-FIX-2: poll pending sales every 10s while session is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadPendingSales();
|
const interval = setInterval(() => {
|
||||||
|
void loadPendingSales();
|
||||||
|
}, 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
}, [loadPendingSales]);
|
}, [loadPendingSales]);
|
||||||
|
|
||||||
|
// POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config?.terminal?.id || config.session?.status !== 'OPEN') return;
|
||||||
|
void loadPendingSales();
|
||||||
|
}, [config?.terminal?.id, config?.session?.status, loadPendingSales]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config?.session || config.session.status !== 'OPEN') return;
|
if (!config?.session || config.session.status !== 'OPEN') return;
|
||||||
void posApi
|
void posApi
|
||||||
@@ -276,6 +327,7 @@ export default function RegisterPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
showAddedToast(product.name);
|
||||||
resetAllocations();
|
resetAllocations();
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -606,11 +658,25 @@ export default function RegisterPage() {
|
|||||||
className="hidden w-64 shrink-0 flex-col border-r bg-amber-50/40 p-3 lg:flex"
|
className="hidden w-64 shrink-0 flex-col border-r bg-amber-50/40 p-3 lg:flex"
|
||||||
aria-label="Pendientes de caja"
|
aria-label="Pendientes de caja"
|
||||||
>
|
>
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-bold text-[#2D6A4F]">Pendientes de caja</h2>
|
<div>
|
||||||
<p className="text-xs text-gray-500">
|
<h2 className="text-sm font-bold text-[#2D6A4F]">Pendientes de caja</h2>
|
||||||
Ventas con saldo pendiente en esta sesión.
|
<p className="text-xs text-gray-500">
|
||||||
</p>
|
Ventas con saldo pendiente en esta sesión.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadPendingSales()}
|
||||||
|
disabled={loadingPending}
|
||||||
|
title="Actualizar pendientes"
|
||||||
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{loadingPending ? (
|
{loadingPending ? (
|
||||||
<p className="text-xs text-gray-500">Cargando…</p>
|
<p className="text-xs text-gray-500">Cargando…</p>
|
||||||
@@ -640,6 +706,18 @@ export default function RegisterPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
{/* POS-FIX-4: product added toast */}
|
||||||
|
{addedToast && (
|
||||||
|
<div className="pointer-events-none fixed top-6 right-6 z-50 animate-in slide-in-from-top-2 fade-in duration-200">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-green-600 px-4 py-3 text-sm font-bold text-white shadow-lg">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{addedToast}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4">
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -647,6 +725,22 @@ export default function RegisterPage() {
|
|||||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
|
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
|
||||||
Caja abierta
|
Caja abierta
|
||||||
</span>
|
</span>
|
||||||
|
{!config?.terminal?.settings?.selfpayMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setClosingActualCash('');
|
||||||
|
setClosingPin('');
|
||||||
|
setClosingPinError('');
|
||||||
|
setClosingPinStep(false);
|
||||||
|
setCloseError('');
|
||||||
|
setShowCloseSession(true);
|
||||||
|
}}
|
||||||
|
className="ml-auto rounded-lg border border-red-200 bg-red-50 px-3 py-1 text-xs font-bold text-red-600 hover:bg-red-100"
|
||||||
|
>
|
||||||
|
Cerrar caja
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1162,6 +1256,86 @@ export default function RegisterPage() {
|
|||||||
onClose={() => setPaymentMethod(null)}
|
onClose={() => setPaymentMethod(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* POS-FIX-3/POS-FIX-5: close session — 2-step: PIN then cash */}
|
||||||
|
{showCloseSession && (
|
||||||
|
(config?.terminal?.settings?.closeSessionRequiresPin && !closingPinStep) ? (
|
||||||
|
// STEP 1: PIN
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
|
||||||
|
<h2 className="mb-2 text-lg font-bold text-gray-900">Cerrar caja</h2>
|
||||||
|
<p className="mb-4 text-sm text-gray-500">Introduce tu PIN de cajero para autorizar el cierre.</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={closingPin}
|
||||||
|
onChange={(e) => { setClosingPin(e.target.value); setClosingPinError(''); }}
|
||||||
|
placeholder="PIN"
|
||||||
|
className="mb-1 w-full rounded-xl border border-gray-300 px-3 py-2 text-center text-2xl tracking-widest outline-none focus:border-[#2D6A4F]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{closingPinError && <p className="mb-3 text-xs text-red-600">{closingPinError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowCloseSession(false); setClosingPinStep(false); }}
|
||||||
|
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleVerifyPin()}
|
||||||
|
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Confirmar PIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// STEP 2: cash amount
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||||
|
<h2 className="mb-4 text-lg font-bold text-gray-900">Cerrar caja</h2>
|
||||||
|
<p className="mb-4 text-sm text-gray-600">
|
||||||
|
Indica el efectivo real en caja para calcular la diferencia.
|
||||||
|
</p>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
Efectivo real (céntimos)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={closingActualCash}
|
||||||
|
onChange={(e) => setClosingActualCash(e.target.value)}
|
||||||
|
className="mb-1 w-full rounded-xl border border-gray-300 px-3 py-2 text-lg outline-none focus:border-[#2D6A4F]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{closeError && <p className="mb-3 text-xs text-red-600">{closeError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowCloseSession(false); setClosingPinStep(false); }}
|
||||||
|
disabled={closing}
|
||||||
|
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCloseSession()}
|
||||||
|
disabled={closing}
|
||||||
|
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{closing ? 'Cerrando…' : 'Confirmar cierre'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{receipt && (
|
{receipt && (
|
||||||
<ReceiptModal
|
<ReceiptModal
|
||||||
receipt={receipt}
|
receipt={receipt}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export const posApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ openingCashCents }),
|
body: JSON.stringify({ openingCashCents }),
|
||||||
}),
|
}),
|
||||||
|
/** Close the daily cash session. */
|
||||||
|
closeSession: (sessionId: string, actualCashCents: number, pin?: string) =>
|
||||||
|
apiFetch<{ id: string; status: 'CLOSED' }>(`/pos/sessions/${sessionId}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(pin
|
||||||
|
? { closingCashCents: actualCashCents, actualCashCents, pin }
|
||||||
|
: { closingCashCents: actualCashCents, actualCashCents }),
|
||||||
|
}),
|
||||||
/** Load touch category navigation and eight terminal quick products. */
|
/** Load touch category navigation and eight terminal quick products. */
|
||||||
touchCatalog: <T>() => apiFetch<T>('/pos/catalog/touch'),
|
touchCatalog: <T>() => apiFetch<T>('/pos/catalog/touch'),
|
||||||
/** List products by query. */
|
/** List products by query. */
|
||||||
@@ -76,10 +84,12 @@ export const posApi = {
|
|||||||
listOrderItems: <T>(orderId: string) =>
|
listOrderItems: <T>(orderId: string) =>
|
||||||
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
|
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
|
||||||
/** List POS sales for the session, optionally filtered by state. */
|
/** List POS sales for the session, optionally filtered by state. */
|
||||||
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
|
// FEAT-200: terminalId enables cross-day pending sales
|
||||||
|
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string; terminalId?: string }) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params?.state) qs.set('state', params.state);
|
if (params?.state) qs.set('state', params.state);
|
||||||
if (params?.sessionId) qs.set('sessionId', params.sessionId);
|
if (params?.sessionId) qs.set('sessionId', params.sessionId);
|
||||||
|
if (params?.terminalId) qs.set('terminalId', params.terminalId);
|
||||||
const tail = qs.toString();
|
const tail = qs.toString();
|
||||||
return apiFetch<T>(`/pos/sales${tail ? `?${tail}` : ''}`);
|
return apiFetch<T>(`/pos/sales${tail ? `?${tail}` : ''}`);
|
||||||
},
|
},
|
||||||
|
|||||||
16
project/frontend/src/app/api/auth/confirm/route.ts
Normal file
16
project/frontend/src/app/api/auth/confirm/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: { message: 'Token requerido' } }, { status: 400 });
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API}/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
74
project/frontend/src/app/auth/confirm/page.tsx
Normal file
74
project/frontend/src/app/auth/confirm/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function ConfirmPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const token = params.get('token');
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
|
const [message, setMessage] = useState('Confirmando tu email…');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('Falta el token de confirmación.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/auth/confirm?token=${encodeURIComponent(token)}`)
|
||||||
|
.then(async (r) => {
|
||||||
|
const data = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
setStatus('success');
|
||||||
|
setMessage(data.message ?? 'Email confirmado.');
|
||||||
|
setTimeout(() => router.push('/auth/login'), 3000);
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(data.error?.message ?? 'Token inválido o ya confirmado.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('Error de conexión.');
|
||||||
|
});
|
||||||
|
}, [token, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto px-4 py-16">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<svg className="animate-spin mx-auto" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#70ad47" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'success' && (
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||||
|
{status === 'success' ? '¡Email confirmado!' : status === 'error' ? 'Error' : 'Confirmando…'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{message}</p>
|
||||||
|
{status === 'success' && (
|
||||||
|
<p className="text-sm text-gray-400 mb-4">Redirigiendo al login…</p>
|
||||||
|
)}
|
||||||
|
<Link href="/auth/login" className="text-[#70ad47] hover:underline text-sm">
|
||||||
|
Ir a iniciar sesión
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export default function RegisterPage() {
|
|||||||
const [confirm, setConfirm] = useState('');
|
const [confirm, setConfirm] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -24,12 +25,36 @@ export default function RegisterPage() {
|
|||||||
const result = await register(email, password);
|
const result = await register(email, password);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
router.push('/');
|
setDone(true);
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Error al crear cuenta');
|
setError(result.error || 'Error al crear cuenta');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto px-4 py-16">
|
||||||
|
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm text-center">
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||||
|
¡Revisa tu correo!
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Hemos enviado un enlace de confirmación a <strong>{email}</strong>.<br/>
|
||||||
|
Haz clic en el enlace para activar tu cuenta.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
¿No lo recibiste? <Link href="/auth/register" className="text-[#70ad47] hover:underline">Inténtalo de nuevo</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto px-4 py-16">
|
<div className="max-w-md mx-auto px-4 py-16">
|
||||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ function addressToForm(addr: SavedAddress) {
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: addr.street,
|
address: addr.street,
|
||||||
city: addr.city,
|
city: addr.city,
|
||||||
postalCode: addr.postalCode,
|
postalCode: addr.postalCode ?? '',
|
||||||
country: addr.country,
|
country: addr.country ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// FIX-198: must send credentials so the session cookie is included.
|
||||||
|
// FIX-198: /auth/me returns {id,email,role} when authenticated, {user:null} when not.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me', { credentials: 'include' })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setUser(data.user ?? null);
|
// When authenticated: { id, email, role }. When not: { user: null }.
|
||||||
|
setUser(data.user ?? (data.id ? data : null));
|
||||||
})
|
})
|
||||||
.catch(() => setUser(null))
|
.catch(() => setUser(null))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|||||||
39
project/migrations/058_identity_email_confirmation.js
Normal file
39
project/migrations/058_identity_email_confirmation.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FEAT-199: Adds email confirmation to user registration.
|
||||||
|
* - confirmation_token: random string sent in confirmation email (null after confirmed)
|
||||||
|
* - confirmed_at: timestamp when email was confirmed (null until confirmed)
|
||||||
|
* - confirmed users can login; unconfirmed cannot.
|
||||||
|
*/
|
||||||
|
exports.up = function (db) {
|
||||||
|
db.addColumn('identity_users', 'confirmation_token', {
|
||||||
|
type: 'string',
|
||||||
|
notNull: false,
|
||||||
|
default: null,
|
||||||
|
});
|
||||||
|
db.addColumn('identity_users', 'confirmed_at', {
|
||||||
|
type: 'timestamp',
|
||||||
|
notNull: false,
|
||||||
|
default: null,
|
||||||
|
});
|
||||||
|
db.addColumn('identity_users', 'email_confirmed', {
|
||||||
|
type: 'boolean',
|
||||||
|
notNull: true,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
// FEAT-199: migrate existing users to confirmed (they already verified their email during signup)
|
||||||
|
return db.execute('UPDATE identity_users SET email_confirmed = true');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db) {
|
||||||
|
db.removeColumn('identity_users', 'email_confirmed');
|
||||||
|
db.removeColumn('identity_users', 'confirmed_at');
|
||||||
|
db.removeColumn('identity_users', 'confirmation_token');
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports._meta = {
|
||||||
|
version: 58,
|
||||||
|
};
|
||||||
@@ -79,7 +79,14 @@ export async function registerIdentityRoutes(
|
|||||||
const sessions = new PgSessionRepository(deps.pool);
|
const sessions = new PgSessionRepository(deps.pool);
|
||||||
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
|
const rateLimiter = deps.rateLimiter ?? new InMemoryLoginRateLimiter();
|
||||||
|
|
||||||
const registerUser = new RegisterUser(users, hasher);
|
const registerUser = new RegisterUser({
|
||||||
|
users,
|
||||||
|
hasher,
|
||||||
|
generateToken: () => crypto.randomUUID(),
|
||||||
|
sendConfirmationEmail: deps.welcomeMailer?.sendConfirmation?.bind(deps.welcomeMailer),
|
||||||
|
buildConfirmUrl: (token: string) =>
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'}/auth/confirm?token=${encodeURIComponent(token)}`,
|
||||||
|
});
|
||||||
const welcomeMailer = deps.welcomeMailer;
|
const welcomeMailer = deps.welcomeMailer;
|
||||||
const login = new Login({
|
const login = new Login({
|
||||||
users,
|
users,
|
||||||
@@ -182,22 +189,36 @@ export async function registerIdentityRoutes(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// FEAT-199: confirmation email route
|
||||||
|
app.get('/auth/confirm', {
|
||||||
|
schema: {
|
||||||
|
tags: ['Auth'],
|
||||||
|
summary: 'Confirm email address',
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['token'],
|
||||||
|
properties: { token: { type: 'string', minLength: 16 } },
|
||||||
|
},
|
||||||
|
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } },
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token } = request.query as { token: string };
|
||||||
|
const confirmed = await users.confirmByToken(token);
|
||||||
|
if (!confirmed) {
|
||||||
|
throw new AppError(400, 'INVALID_CONFIRMATION_TOKEN', 'Token inválido o ya confirmado');
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true, message: 'Email confirmado. Ya puedes iniciar sesión.' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post('/auth/register', { schema: registerSchema }, async (request, reply) => {
|
app.post('/auth/register', { schema: registerSchema }, async (request, reply) => {
|
||||||
const input = parseJson(credentialsSchema, request.body);
|
const input = parseJson(credentialsSchema, request.body);
|
||||||
try {
|
try {
|
||||||
const user = await registerUser.execute(input);
|
const user = await registerUser.execute(input);
|
||||||
// F-152: best-effort welcome email. Never blocks account creation; a
|
|
||||||
// delivery failure is logged and swallowed.
|
|
||||||
if (welcomeMailer) {
|
|
||||||
void welcomeMailer
|
|
||||||
.sendWelcome({ email: user.email })
|
|
||||||
.catch((error) =>
|
|
||||||
request.log.warn({ err: error, userId: user.id }, 'welcome_email_failed'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return reply
|
return reply
|
||||||
.code(201)
|
.code(201)
|
||||||
.send({ id: user.id, email: user.email, role: user.role, createdAt: user.createdAt });
|
.send({ id: user.id, email: user.email, role: user.role, message: 'Cuenta creada. Revisa tu correo para confirmar tu email.', createdAt: user.createdAt });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof EmailAlreadyRegisteredError) {
|
if (error instanceof EmailAlreadyRegisteredError) {
|
||||||
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
throw new AppError(409, 'EMAIL_ALREADY_REGISTERED', 'Email already registered');
|
||||||
@@ -220,6 +241,9 @@ export async function registerIdentityRoutes(
|
|||||||
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
||||||
}
|
}
|
||||||
if (error instanceof InvalidCredentialsError) {
|
if (error instanceof InvalidCredentialsError) {
|
||||||
|
if (error.code === 'EMAIL_NOT_CONFIRMED') {
|
||||||
|
throw new AppError(403, 'EMAIL_NOT_CONFIRMED', 'Email no confirmado. Revisa tu correo.');
|
||||||
|
}
|
||||||
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ export class Login {
|
|||||||
throw new InvalidCredentialsError();
|
throw new InvalidCredentialsError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FEAT-199: require email confirmation before login
|
||||||
|
// Default to confirmed for existing users without the field (backward compat during migration)
|
||||||
|
if (record.emailConfirmed === false) {
|
||||||
|
this.deps.rateLimiter.recordFailure(email);
|
||||||
|
throw new InvalidCredentialsError('Email no confirmado. Revisa tu correo.', 'EMAIL_NOT_CONFIRMED');
|
||||||
|
}
|
||||||
|
|
||||||
this.deps.rateLimiter.reset(email);
|
this.deps.rateLimiter.reset(email);
|
||||||
|
|
||||||
const token = this.deps.generateToken();
|
const token = this.deps.generateToken();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* RegisterUser use case. Orchestrates domain + ports; knows no HTTP.
|
* RegisterUser use case. Orchestrates domain + ports; knows no HTTP.
|
||||||
|
* FEAT-199: creates unconfirmed user, generates confirmation token, sends confirmation email.
|
||||||
*/
|
*/
|
||||||
import type { PasswordHasher, UserRepository } from '../domain/ports.js';
|
import type { PasswordHasher, UserRepository } from '../domain/ports.js';
|
||||||
import type { User } from '../domain/user.js';
|
import type { User } from '../domain/user.js';
|
||||||
@@ -10,15 +11,32 @@ export interface RegisterInput {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterDeps {
|
||||||
|
users: UserRepository;
|
||||||
|
hasher: PasswordHasher;
|
||||||
|
generateToken: () => string;
|
||||||
|
/** Best-effort: delivery failure must NOT block registration. */
|
||||||
|
sendConfirmationEmail?: (input: { email: string; confirmUrl: string }) => Promise<void>;
|
||||||
|
/** Build absolute confirmation URL from raw token. */
|
||||||
|
buildConfirmUrl: (token: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
export class RegisterUser {
|
export class RegisterUser {
|
||||||
constructor(
|
constructor(private readonly deps: RegisterDeps) {}
|
||||||
private readonly users: UserRepository,
|
|
||||||
private readonly hasher: PasswordHasher,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(input: RegisterInput): Promise<User> {
|
async execute(input: RegisterInput): Promise<User> {
|
||||||
const email = normalizeEmail(input.email);
|
const email = normalizeEmail(input.email);
|
||||||
const passwordHash = await this.hasher.hash(input.password);
|
const passwordHash = await this.deps.hasher.hash(input.password);
|
||||||
return this.users.create({ email, passwordHash });
|
const confirmationToken = this.deps.generateToken();
|
||||||
|
const user = await this.deps.users.create({ email, passwordHash, confirmationToken });
|
||||||
|
if (this.deps.sendConfirmationEmail) {
|
||||||
|
void this.deps.sendConfirmationEmail({
|
||||||
|
email,
|
||||||
|
confirmUrl: this.deps.buildConfirmUrl(confirmationToken),
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.error('confirmation_email_failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export class InvalidCredentialsError extends Error {
|
export class InvalidCredentialsError extends Error {
|
||||||
constructor() {
|
public readonly code: string;
|
||||||
super('Invalid credentials');
|
constructor(message?: string, code = 'INVALID_CREDENTIALS') {
|
||||||
|
super(message ?? 'Invalid credentials');
|
||||||
this.name = 'InvalidCredentialsError';
|
this.name = 'InvalidCredentialsError';
|
||||||
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface PasswordHasher {
|
|||||||
|
|
||||||
export interface UserRepository {
|
export interface UserRepository {
|
||||||
create(user: NewUser): Promise<User>;
|
create(user: NewUser): Promise<User>;
|
||||||
findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined>;
|
findByEmail(email: string): Promise<(User & { passwordHash: string; emailConfirmed: boolean; confirmationToken?: string | null }) | undefined>;
|
||||||
findById(id: string): Promise<User | undefined>;
|
findById(id: string): Promise<User | undefined>;
|
||||||
listUsers(params?: {
|
listUsers(params?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -22,6 +22,8 @@ export interface UserRepository {
|
|||||||
}): Promise<{ items: User[]; total: number }>;
|
}): Promise<{ items: User[]; total: number }>;
|
||||||
updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise<User>;
|
updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise<User>;
|
||||||
deleteUser(id: string): Promise<void>;
|
deleteUser(id: string): Promise<void>;
|
||||||
|
findByConfirmationToken(token: string): Promise<User | undefined>;
|
||||||
|
confirmByToken(token: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionRepository {
|
export interface SessionRepository {
|
||||||
@@ -60,4 +62,6 @@ export interface PasswordResetMailer {
|
|||||||
* swallow errors so a delivery failure never blocks registration. */
|
* swallow errors so a delivery failure never blocks registration. */
|
||||||
export interface WelcomeMailer {
|
export interface WelcomeMailer {
|
||||||
sendWelcome(input: { email: string; name?: string }): Promise<void>;
|
sendWelcome(input: { email: string; name?: string }): Promise<void>;
|
||||||
|
/** FEAT-199: sends email confirmation link. Best-effort. */
|
||||||
|
sendConfirmation?(input: { email: string; confirmUrl: string }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
emailConfirmed?: boolean;
|
||||||
|
confirmationToken?: string | null;
|
||||||
|
confirmedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewUser {
|
export interface NewUser {
|
||||||
email: string;
|
email: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
|
confirmationToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical form: trimmed + lowercased. citext backs uniqueness in the DB. */
|
/** Canonical form: trimmed + lowercased. citext backs uniqueness in the DB. */
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface UserRow {
|
|||||||
password_hash: string;
|
password_hash: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
email_confirmed: boolean;
|
||||||
|
confirmation_token: string | null;
|
||||||
|
confirmed_at: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNIQUE_VIOLATION = '23505';
|
const UNIQUE_VIOLATION = '23505';
|
||||||
@@ -22,18 +25,27 @@ export class PgUserRepository implements UserRepository {
|
|||||||
constructor(private readonly pool: pg.Pool) {}
|
constructor(private readonly pool: pg.Pool) {}
|
||||||
|
|
||||||
async create(user: NewUser): Promise<User> {
|
async create(user: NewUser): Promise<User> {
|
||||||
|
const confirmationToken = (user as { confirmationToken?: string }).confirmationToken;
|
||||||
try {
|
try {
|
||||||
const result = await this.pool.query<UserRow>(
|
const result = await this.pool.query<UserRow>(
|
||||||
`INSERT INTO identity_users (email, password_hash)
|
`INSERT INTO identity_users (email, password_hash, email_confirmed, confirmation_token)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, email, role, created_at`,
|
RETURNING id, email, role, created_at, email_confirmed, confirmation_token, confirmed_at`,
|
||||||
[user.email, user.passwordHash],
|
[user.email, user.passwordHash, false, confirmationToken ?? null],
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new Error('identity_users INSERT returned no row');
|
throw new Error('identity_users INSERT returned no row');
|
||||||
}
|
}
|
||||||
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
emailConfirmed: row.email_confirmed,
|
||||||
|
confirmationToken: row.confirmation_token,
|
||||||
|
confirmedAt: row.confirmed_at,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
|
if (isPgError(error) && error.code === UNIQUE_VIOLATION) {
|
||||||
throw new EmailAlreadyRegisteredError();
|
throw new EmailAlreadyRegisteredError();
|
||||||
@@ -42,9 +54,9 @@ export class PgUserRepository implements UserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined> {
|
async findByEmail(email: string): Promise<(User & { passwordHash: string; emailConfirmed: boolean; confirmationToken: string | null }) | undefined> {
|
||||||
const result = await this.pool.query<UserRow>(
|
const result = await this.pool.query<UserRow>(
|
||||||
`SELECT id, email, password_hash, role, created_at
|
`SELECT id, email, password_hash, role, created_at, email_confirmed, confirmation_token, confirmed_at
|
||||||
FROM identity_users
|
FROM identity_users
|
||||||
WHERE email = $1`,
|
WHERE email = $1`,
|
||||||
[email],
|
[email],
|
||||||
@@ -59,9 +71,42 @@ export class PgUserRepository implements UserRepository {
|
|||||||
role: row.role,
|
role: row.role,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
passwordHash: row.password_hash,
|
passwordHash: row.password_hash,
|
||||||
|
emailConfirmed: row.email_confirmed,
|
||||||
|
confirmationToken: row.confirmation_token,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByConfirmationToken(token: string): Promise<User | undefined> {
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`SELECT id, email, role, created_at, email_confirmed, confirmation_token, confirmed_at
|
||||||
|
FROM identity_users
|
||||||
|
WHERE confirmation_token = $1 AND email_confirmed = false`,
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return undefined;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
emailConfirmed: row.email_confirmed,
|
||||||
|
confirmationToken: row.confirmation_token,
|
||||||
|
confirmedAt: row.confirmed_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmByToken(token: string): Promise<boolean> {
|
||||||
|
const result = await this.pool.query<UserRow>(
|
||||||
|
`UPDATE identity_users
|
||||||
|
SET email_confirmed = true, confirmed_at = now(), confirmation_token = null
|
||||||
|
WHERE confirmation_token = $1 AND email_confirmed = false
|
||||||
|
RETURNING id`,
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
return (result.rows[0]?.id ?? null) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<User | undefined> {
|
async findById(id: string): Promise<User | undefined> {
|
||||||
const result = await this.pool.query<UserRow>(
|
const result = await this.pool.query<UserRow>(
|
||||||
`SELECT id, email, role, created_at
|
`SELECT id, email, role, created_at
|
||||||
|
|||||||
@@ -63,6 +63,37 @@ export function buildWelcomeEmail(input: { email: string; name?: string }): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FEAT-199: builds the confirmation email body.
|
||||||
|
*/
|
||||||
|
export function buildConfirmEmail(input: { email: string; confirmUrl: string }): {
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
subject: 'Confirma tu cuenta en Mercado de Vida',
|
||||||
|
text: [
|
||||||
|
'Hola,',
|
||||||
|
'',
|
||||||
|
'Gracias por crear tu cuenta en Mercado de Vida.',
|
||||||
|
'',
|
||||||
|
'Para activar tu cuenta, haz clic en el siguiente enlace:',
|
||||||
|
'',
|
||||||
|
input.confirmUrl,
|
||||||
|
'',
|
||||||
|
'Si no has creado esta cuenta, puedes ignorar este email.',
|
||||||
|
].join('\n'),
|
||||||
|
html: [
|
||||||
|
'<p>Hola,</p>',
|
||||||
|
'<p>Gracias por crear tu cuenta en <strong>Mercado de Vida</strong>.</p>',
|
||||||
|
'<p>Para activar tu cuenta, haz clic en el siguiente enlace:</p>',
|
||||||
|
`<p><a href="${input.confirmUrl}" style="background:#22c55e;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;display:inline-block">Confirmar mi cuenta</a></p>`,
|
||||||
|
'<p>Si no has creado esta cuenta, puedes ignorar este email.</p>',
|
||||||
|
].join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends welcome emails through the SMTP configuration stored in store_settings
|
* Sends welcome emails through the SMTP configuration stored in store_settings
|
||||||
* (Ajustes → SMTP / Email), with env-free config so admins can change it
|
* (Ajustes → SMTP / Email), with env-free config so admins can change it
|
||||||
@@ -90,6 +121,24 @@ export class SettingsWelcomeMailer implements WelcomeMailer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendConfirmation(input: { email: string; confirmUrl: string }): Promise<void> {
|
||||||
|
const options = await this.readSmtpOptions();
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: options.host,
|
||||||
|
port: options.port,
|
||||||
|
secure: options.secure,
|
||||||
|
auth: { user: options.user, pass: options.password },
|
||||||
|
});
|
||||||
|
const body = buildConfirmEmail(input);
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: options.from,
|
||||||
|
to: input.email,
|
||||||
|
subject: body.subject,
|
||||||
|
text: body.text,
|
||||||
|
html: body.html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async readSmtpOptions(): Promise<SmtpOptions> {
|
private async readSmtpOptions(): Promise<SmtpOptions> {
|
||||||
const result = await this.pool.query<{ key: string; value: string }>(
|
const result = await this.pool.query<{ key: string; value: string }>(
|
||||||
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
|
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ describe('password reset', () => {
|
|||||||
listUsers: vi.fn(),
|
listUsers: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
|
findByConfirmationToken: vi.fn(),
|
||||||
|
confirmByToken: vi.fn(),
|
||||||
};
|
};
|
||||||
const tokens: PasswordResetTokenRepository = {
|
const tokens: PasswordResetTokenRepository = {
|
||||||
invalidateAllForUser: vi.fn(),
|
invalidateAllForUser: vi.fn(),
|
||||||
@@ -84,6 +86,8 @@ describe('password reset', () => {
|
|||||||
listUsers: vi.fn(),
|
listUsers: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
|
findByConfirmationToken: vi.fn(),
|
||||||
|
confirmByToken: vi.fn(),
|
||||||
};
|
};
|
||||||
const tokens: PasswordResetTokenRepository = {
|
const tokens: PasswordResetTokenRepository = {
|
||||||
invalidateAllForUser: vi.fn().mockResolvedValue(undefined),
|
invalidateAllForUser: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -119,6 +123,8 @@ describe('password reset', () => {
|
|||||||
listUsers: vi.fn(),
|
listUsers: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
|
findByConfirmationToken: vi.fn(),
|
||||||
|
confirmByToken: vi.fn(),
|
||||||
};
|
};
|
||||||
const tokens: PasswordResetTokenRepository = {
|
const tokens: PasswordResetTokenRepository = {
|
||||||
invalidateAllForUser: vi.fn(),
|
invalidateAllForUser: vi.fn(),
|
||||||
@@ -156,6 +162,8 @@ describe('password reset', () => {
|
|||||||
listUsers: vi.fn(),
|
listUsers: vi.fn(),
|
||||||
updateUser: vi.fn().mockResolvedValue({ id: 'u-1', email: 'a', role: 'customer', createdAt: new Date() }),
|
updateUser: vi.fn().mockResolvedValue({ id: 'u-1', email: 'a', role: 'customer', createdAt: new Date() }),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
|
findByConfirmationToken: vi.fn(),
|
||||||
|
confirmByToken: vi.fn(),
|
||||||
};
|
};
|
||||||
const hasher: PasswordHasher = { hash: vi.fn().mockResolvedValue('NEWHASH'), verify: vi.fn() };
|
const hasher: PasswordHasher = { hash: vi.fn().mockResolvedValue('NEWHASH'), verify: vi.fn() };
|
||||||
const audit = vi.fn();
|
const audit = vi.fn();
|
||||||
@@ -183,6 +191,8 @@ describe('password reset', () => {
|
|||||||
listUsers: vi.fn(),
|
listUsers: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
|
findByConfirmationToken: vi.fn(),
|
||||||
|
confirmByToken: vi.fn(),
|
||||||
};
|
};
|
||||||
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
|
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
|
||||||
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
|
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
|
||||||
@@ -205,6 +215,8 @@ describe('password reset', () => {
|
|||||||
listUsers: vi.fn(),
|
listUsers: vi.fn(),
|
||||||
updateUser: vi.fn(),
|
updateUser: vi.fn(),
|
||||||
deleteUser: vi.fn(),
|
deleteUser: vi.fn(),
|
||||||
|
findByConfirmationToken: vi.fn(),
|
||||||
|
confirmByToken: vi.fn(),
|
||||||
};
|
};
|
||||||
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
|
const hasher: PasswordHasher = { hash: vi.fn(), verify: vi.fn() };
|
||||||
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
|
const useCase = new ConfirmPasswordReset({ tokens, users, hasher });
|
||||||
|
|||||||
@@ -78,10 +78,11 @@ export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<Orde
|
|||||||
PROCESSING: ['PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
|
PROCESSING: ['PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
|
||||||
SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'],
|
SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'],
|
||||||
DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'],
|
DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'],
|
||||||
COMPLETED: [],
|
// POS returns: COMPLETED orders can be fully or partially returned
|
||||||
|
COMPLETED: ['REFUNDED', 'PARTIALLY_REFUNDED'],
|
||||||
CANCELLED: [],
|
CANCELLED: [],
|
||||||
REFUNDED: [],
|
REFUNDED: [],
|
||||||
PARTIALLY_REFUNDED: [],
|
PARTIALLY_REFUNDED: ['REFUNDED'], // partial → full refund
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isTransitionAllowed(from: OrderState, to: OrderState): boolean {
|
export function isTransitionAllowed(from: OrderState, to: OrderState): boolean {
|
||||||
|
|||||||
@@ -31,10 +31,22 @@ describe('Order state machine', () => {
|
|||||||
expect(isTransitionAllowed('DELIVERED', 'SHIPPED')).toBe(true);
|
expect(isTransitionAllowed('DELIVERED', 'SHIPPED')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps REFUNDED and PARTIALLY_REFUNDED terminal', () => {
|
// POS-FIX-1: COMPLETED can be returned; PARTIALLY_REFUNDED can become full REFUNDED
|
||||||
|
it('POS-FIX-1: COMPLETED orders can be refunded', () => {
|
||||||
|
expect(ALLOWED_TRANSITIONS.COMPLETED).toContain('REFUNDED');
|
||||||
|
expect(ALLOWED_TRANSITIONS.COMPLETED).toContain('PARTIALLY_REFUNDED');
|
||||||
|
expect(isTransitionAllowed('COMPLETED', 'REFUNDED')).toBe(true);
|
||||||
|
expect(isTransitionAllowed('COMPLETED', 'PARTIALLY_REFUNDED')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps REFUNDED terminal', () => {
|
||||||
expect(ALLOWED_TRANSITIONS.REFUNDED).toEqual([]);
|
expect(ALLOWED_TRANSITIONS.REFUNDED).toEqual([]);
|
||||||
expect(ALLOWED_TRANSITIONS.PARTIALLY_REFUNDED).toEqual([]);
|
|
||||||
expect(isTransitionAllowed('REFUNDED', 'PAID')).toBe(false);
|
expect(isTransitionAllowed('REFUNDED', 'PAID')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POS-FIX-1: PARTIALLY_REFUNDED can complete to full REFUNDED', () => {
|
||||||
|
expect(ALLOWED_TRANSITIONS.PARTIALLY_REFUNDED).toEqual(['REFUNDED']);
|
||||||
|
expect(isTransitionAllowed('PARTIALLY_REFUNDED', 'REFUNDED')).toBe(true);
|
||||||
expect(isTransitionAllowed('PARTIALLY_REFUNDED', 'PENDING')).toBe(false);
|
expect(isTransitionAllowed('PARTIALLY_REFUNDED', 'PENDING')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ListTerminalsUseCase } from '../application/list-terminals.js';
|
|||||||
import { GetPosConfigUseCase } from '../application/get-pos-config.js';
|
import { GetPosConfigUseCase } from '../application/get-pos-config.js';
|
||||||
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
||||||
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
||||||
|
import { sendCashCloseReport } from '../infrastructure/cash-close-mailer.js';
|
||||||
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
|
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
|
||||||
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
|
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
|
||||||
import { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
|
import { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
|
||||||
@@ -352,6 +353,63 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POS-FIX-5: update terminal settings (selfpay, close PIN, etc.)
|
||||||
|
app.patch<{ Params: { id: string } }>(
|
||||||
|
'/pos/admin/terminals/:id',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['POS Admin'],
|
||||||
|
summary: 'Update terminal settings',
|
||||||
|
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: [],
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
|
interfaceMode: { type: 'string', enum: ['desktop', 'touch', 'auto'] },
|
||||||
|
settings: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
selfpayMode: { type: 'boolean' },
|
||||||
|
closeSessionRequiresPin: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = await authenticate(request);
|
||||||
|
requireRole(user, 'admin');
|
||||||
|
const { id } = parseJson(idParamSchema, request.params);
|
||||||
|
const body = parseJson(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
interfaceMode: z.enum(['desktop', 'touch', 'auto']).optional(),
|
||||||
|
settings: z.object({
|
||||||
|
selfpayMode: z.boolean().optional(),
|
||||||
|
closeSessionRequiresPin: z.boolean().optional(),
|
||||||
|
}).passthrough().optional(),
|
||||||
|
}),
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
|
const existing = await terminalRepo.findById(id);
|
||||||
|
if (!existing) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal no encontrado');
|
||||||
|
const mergedSettings = body.settings
|
||||||
|
? { ...existing.settings, ...body.settings }
|
||||||
|
: existing.settings;
|
||||||
|
await terminalRepo.update(id, {
|
||||||
|
name: body.name,
|
||||||
|
interfaceMode: body.interfaceMode,
|
||||||
|
settings: mergedSettings,
|
||||||
|
});
|
||||||
|
const updated = await terminalRepo.findById(id);
|
||||||
|
return reply.send(updated);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>(
|
app.delete<{ Params: { id: string } }>(
|
||||||
'/pos/admin/terminals/:id',
|
'/pos/admin/terminals/:id',
|
||||||
{
|
{
|
||||||
@@ -532,6 +590,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
closingCashCents: { type: 'integer', minimum: 0 },
|
closingCashCents: { type: 'integer', minimum: 0 },
|
||||||
actualCashCents: { type: 'integer', minimum: 0 },
|
actualCashCents: { type: 'integer', minimum: 0 },
|
||||||
notes: { type: 'string' },
|
notes: { type: 'string' },
|
||||||
|
pin: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||||
@@ -546,12 +605,48 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
closingCashCents: z.number().int().min(0),
|
closingCashCents: z.number().int().min(0),
|
||||||
actualCashCents: z.number().int().min(0),
|
actualCashCents: z.number().int().min(0),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
pin: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
request.body ?? {},
|
request.body ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POS-FIX-5: validate PIN if terminal requires it
|
||||||
|
const session = await sessionRepo.findById(id);
|
||||||
|
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Session not found');
|
||||||
|
if (session.terminalId) {
|
||||||
|
const terminal = await terminalRepo.findById(session.terminalId);
|
||||||
|
if (terminal?.settings?.closeSessionRequiresPin) {
|
||||||
|
const storedPin = terminal.settings.closeSessionPin as string | undefined;
|
||||||
|
if (!storedPin || body.pin !== storedPin) {
|
||||||
|
throw new AppError(401, 'INVALID_PIN', 'PIN de cajero incorrecto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await closeSession.execute({ sessionId: id, ...body });
|
const result = await closeSession.execute({ sessionId: id, ...body });
|
||||||
return reply.send(session);
|
|
||||||
|
// F-202: send cash close report email (best-effort)
|
||||||
|
void sendCashCloseReport(pool, {
|
||||||
|
sessionId: result.id,
|
||||||
|
storeId: result.storeId,
|
||||||
|
terminalId: result.terminalId,
|
||||||
|
openedAt: result.openedAt,
|
||||||
|
closedAt: result.closedAt ?? new Date(),
|
||||||
|
userId: result.userId,
|
||||||
|
financial: {
|
||||||
|
openingCashCents: result.openingCashCents,
|
||||||
|
closingCashCents: result.closingCashCents ?? 0,
|
||||||
|
actualCashCents: result.actualCashCents ?? 0,
|
||||||
|
expectedCashCents: result.closingCashCents ?? 0,
|
||||||
|
differenceCents: result.differenceCents ?? 0,
|
||||||
|
},
|
||||||
|
sales: { totalCount: 0, completedCount: 0, completedTotalCents: 0, pendingCount: 0, refundedCount: 0, refundedTotalCents: 0, byState: {} },
|
||||||
|
payments: [],
|
||||||
|
items: { soldCount: 0, uniqueProducts: 0 },
|
||||||
|
}).catch(err => console.error('[cash-close] email failed:', err));
|
||||||
|
|
||||||
|
return reply.send(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AppError) throw err;
|
if (err instanceof AppError) throw err;
|
||||||
throw new AppError(409, 'CLOSE_ERROR', String(err));
|
throw new AppError(409, 'CLOSE_ERROR', String(err));
|
||||||
@@ -590,15 +685,159 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
[id],
|
[id],
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
// F-201: extend with payment method breakdown + items sold + sales by state
|
||||||
|
const [byStateResult, paymentResult, itemsResult] = await Promise.all([
|
||||||
|
pool.query<{ state: string; cnt: string; total: string }>(
|
||||||
|
`SELECT state, COUNT(*)::int AS cnt, COALESCE(SUM(total_cents), 0)::bigint AS total
|
||||||
|
FROM orders_orders WHERE cash_session_id = $1 AND source = 'pos'
|
||||||
|
GROUP BY state`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
|
||||||
|
`SELECT pm.code AS method_code, pm.name AS method_name,
|
||||||
|
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM payments_transactions pt
|
||||||
|
JOIN orders_orders o ON o.id = pt.order_id
|
||||||
|
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id
|
||||||
|
AND pm.store_id = o.store_id
|
||||||
|
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
|
||||||
|
GROUP BY pm.code, pm.name`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
pool.query<{ items_count: string; unique_products: string }>(
|
||||||
|
`SELECT COALESCE(SUM(oi.quantity), 0)::bigint AS items_count,
|
||||||
|
COUNT(DISTINCT oi.variant_id)::int AS unique_products
|
||||||
|
FROM orders_items oi
|
||||||
|
JOIN orders_orders o ON o.id = oi.order_id
|
||||||
|
WHERE o.cash_session_id = $1 AND o.source = 'pos'`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const salesByState = byStateResult.rows.reduce((acc, r) => {
|
||||||
|
acc[r.state] = { count: parseInt(r.cnt, 10), totalCents: parseInt(r.total, 10) };
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, { count: number; totalCents: number }>);
|
||||||
|
|
||||||
|
const paymentsByMethod = paymentResult.rows.map(r => ({
|
||||||
|
methodCode: r.method_code ?? 'unknown',
|
||||||
|
methodName: r.method_name ?? 'Otro',
|
||||||
|
totalCents: parseInt(r.total, 10),
|
||||||
|
count: parseInt(r.count, 10),
|
||||||
|
}));
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
...session,
|
...session,
|
||||||
salesCount: parseInt(salesResult.rows[0]?.cnt ?? '0', 10),
|
salesCount: parseInt(salesResult.rows[0]?.cnt ?? '0', 10),
|
||||||
salesTotalCents: parseInt(salesResult.rows[0]?.total ?? '0', 10),
|
salesTotalCents: parseInt(salesResult.rows[0]?.total ?? '0', 10),
|
||||||
|
salesByState,
|
||||||
|
paymentsByMethod,
|
||||||
|
itemsSold: parseInt(itemsResult.rows[0]?.items_count ?? '0', 10),
|
||||||
|
uniqueProductsSold: parseInt(itemsResult.rows[0]?.unique_products ?? '0', 10),
|
||||||
pendingCount: parseInt(pendingResult.rows[0]?.cnt ?? '0', 10),
|
pendingCount: parseInt(pendingResult.rows[0]?.cnt ?? '0', 10),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// F-201: dedicated cash close report endpoint
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/pos/reports/cash-close/:id',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['POS Admin'],
|
||||||
|
summary: 'Cash close report for a closed session',
|
||||||
|
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||||
|
response: { 401: errorSchema, 404: errorSchema },
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = await authenticate(request);
|
||||||
|
requireAnyRole(user, ['admin', 'pos_manager'] as ReadonlyArray<Role>);
|
||||||
|
const { id } = parseJson(idParamSchema, request.params);
|
||||||
|
const session = await sessionRepo.findById(id);
|
||||||
|
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Sesión no encontrada');
|
||||||
|
|
||||||
|
const [salesResult, paymentResult, itemsResult] = await Promise.all([
|
||||||
|
pool.query<{ state: string; cnt: string; total: string }>(
|
||||||
|
`SELECT state, COUNT(*)::int AS cnt, COALESCE(SUM(total_cents), 0)::bigint AS total
|
||||||
|
FROM orders_orders WHERE cash_session_id = $1 AND source = 'pos'
|
||||||
|
GROUP BY state`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
|
||||||
|
`SELECT pm.code AS method_code, pm.name AS method_name,
|
||||||
|
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM payments_transactions pt
|
||||||
|
JOIN orders_orders o ON o.id = pt.order_id
|
||||||
|
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id
|
||||||
|
AND pm.store_id = o.store_id
|
||||||
|
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
|
||||||
|
GROUP BY pm.code, pm.name`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
pool.query<{ items_count: string; unique_products: string }>(
|
||||||
|
`SELECT COALESCE(SUM(oi.quantity), 0)::bigint AS items_count,
|
||||||
|
COUNT(DISTINCT oi.variant_id)::int AS unique_products
|
||||||
|
FROM orders_items oi
|
||||||
|
JOIN orders_orders o ON o.id = oi.order_id
|
||||||
|
WHERE o.cash_session_id = $1 AND o.source = 'pos'`,
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const salesByState = salesResult.rows.reduce((acc, r) => {
|
||||||
|
acc[r.state] = { count: parseInt(r.cnt, 10), totalCents: parseInt(r.total, 10) };
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, { count: number; totalCents: number }>);
|
||||||
|
|
||||||
|
const completedTotal = salesByState['COMPLETED']?.totalCents ?? 0;
|
||||||
|
const openingCash = session.openingCashCents;
|
||||||
|
const expectedCash = completedTotal; // simplified: cash payments only
|
||||||
|
const actualCash = session.actualCashCents ?? 0;
|
||||||
|
const closingCash = session.closingCashCents ?? 0;
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
session: {
|
||||||
|
id: session.id,
|
||||||
|
openedAt: session.openedAt,
|
||||||
|
closedAt: session.closedAt,
|
||||||
|
userId: session.userId,
|
||||||
|
status: session.status,
|
||||||
|
},
|
||||||
|
storeId: session.storeId,
|
||||||
|
terminalId: session.terminalId,
|
||||||
|
financial: {
|
||||||
|
openingCashCents: openingCash,
|
||||||
|
closingCashCents: closingCash,
|
||||||
|
actualCashCents: actualCash,
|
||||||
|
expectedCashCents: expectedCash,
|
||||||
|
differenceCents: (actualCash - closingCash),
|
||||||
|
},
|
||||||
|
sales: {
|
||||||
|
totalCount: Object.values(salesByState).reduce((s, v) => s + v.count, 0),
|
||||||
|
completedCount: salesByState['COMPLETED']?.count ?? 0,
|
||||||
|
completedTotalCents: completedTotal,
|
||||||
|
pendingCount: salesByState['PENDING']?.count ?? 0,
|
||||||
|
refundedCount: salesByState['REFUNDED']?.count ?? 0,
|
||||||
|
refundedTotalCents: salesByState['REFUNDED']?.totalCents ?? 0,
|
||||||
|
byState: salesByState,
|
||||||
|
},
|
||||||
|
payments: paymentResult.rows.map(r => ({
|
||||||
|
methodCode: r.method_code ?? 'unknown',
|
||||||
|
methodName: r.method_name ?? 'Otro',
|
||||||
|
totalCents: parseInt(r.total, 10),
|
||||||
|
transactionCount: parseInt(r.count, 10),
|
||||||
|
})),
|
||||||
|
items: {
|
||||||
|
soldCount: parseInt(itemsResult.rows[0]?.items_count ?? '0', 10),
|
||||||
|
uniqueProducts: parseInt(itemsResult.rows[0]?.unique_products ?? '0', 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
'/pos/catalog/touch',
|
'/pos/catalog/touch',
|
||||||
{
|
{
|
||||||
@@ -1365,6 +1604,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
sessionId: { type: 'string', format: 'uuid' },
|
sessionId: { type: 'string', format: 'uuid' },
|
||||||
|
terminalId: { type: 'string', format: 'uuid' },
|
||||||
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
|
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
|
||||||
storeId: { type: 'string', format: 'uuid' },
|
storeId: { type: 'string', format: 'uuid' },
|
||||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||||
@@ -1378,6 +1618,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||||
const params = request.query as {
|
const params = request.query as {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
terminalId?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -1392,6 +1633,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
values.push(sessionId);
|
values.push(sessionId);
|
||||||
conditions.push(`o.cash_session_id = $${values.length}`);
|
conditions.push(`o.cash_session_id = $${values.length}`);
|
||||||
}
|
}
|
||||||
|
// FEAT-200: filter by terminal for cross-day pending sales
|
||||||
|
if (params.terminalId) {
|
||||||
|
values.push(params.terminalId);
|
||||||
|
conditions.push(`o.terminal_id = $${values.length}`);
|
||||||
|
}
|
||||||
if (state) {
|
if (state) {
|
||||||
values.push(state);
|
values.push(state);
|
||||||
conditions.push(`o.state = $${values.length}`);
|
conditions.push(`o.state = $${values.length}`);
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface PosTerminalRepository {
|
|||||||
list(options?: ListTerminalsOptions): Promise<{ terminals: PosTerminal[]; total: number }>;
|
list(options?: ListTerminalsOptions): Promise<{ terminals: PosTerminal[]; total: number }>;
|
||||||
updateLastSeen(id: string): Promise<void>;
|
updateLastSeen(id: string): Promise<void>;
|
||||||
bind(id: string, bindingCode: string): Promise<PosTerminal>;
|
bind(id: string, bindingCode: string): Promise<PosTerminal>;
|
||||||
|
update(id: string, patch: {
|
||||||
|
name?: string;
|
||||||
|
interfaceMode?: string;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PosCashSessionRepository {
|
export interface PosCashSessionRepository {
|
||||||
|
|||||||
136
project/src/modules/pos/infrastructure/cash-close-mailer.ts
Normal file
136
project/src/modules/pos/infrastructure/cash-close-mailer.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type pg from 'pg';
|
||||||
|
import { sendTransactionalEmail } from '../../notifications/infrastructure/settings-email-provider.js';
|
||||||
|
|
||||||
|
interface CashCloseReport {
|
||||||
|
sessionId: string;
|
||||||
|
storeId: string;
|
||||||
|
terminalId: string;
|
||||||
|
openedAt: Date;
|
||||||
|
closedAt: Date;
|
||||||
|
userId: string;
|
||||||
|
financial: {
|
||||||
|
openingCashCents: number;
|
||||||
|
closingCashCents: number;
|
||||||
|
actualCashCents: number;
|
||||||
|
expectedCashCents: number;
|
||||||
|
differenceCents: number;
|
||||||
|
};
|
||||||
|
sales: {
|
||||||
|
totalCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
completedTotalCents: number;
|
||||||
|
pendingCount: number;
|
||||||
|
refundedCount: number;
|
||||||
|
refundedTotalCents: number;
|
||||||
|
byState: Record<string, { count: number; totalCents: number }>;
|
||||||
|
};
|
||||||
|
payments: Array<{
|
||||||
|
methodCode: string;
|
||||||
|
methodName: string;
|
||||||
|
totalCents: number;
|
||||||
|
transactionCount: number;
|
||||||
|
}>;
|
||||||
|
items: {
|
||||||
|
soldCount: number;
|
||||||
|
uniqueProducts: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(cents: number): string {
|
||||||
|
return (cents / 100).toFixed(2) + ' \u20ac';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d: Date): string {
|
||||||
|
return new Date(d).toLocaleString('es-ES', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tr(label: string, value: string): string {
|
||||||
|
return `<tr><td style="padding:4px 8px;border-bottom:1px solid #eee;font-size:14px">${label}</td>` +
|
||||||
|
`<td style="padding:4px 8px;border-bottom:1px solid #eee;font-size:14px;text-align:right;font-weight:bold">${value}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlTable(rows: Array<{ label: string; value: string }>): string {
|
||||||
|
return `<table style="border-collapse:collapse;width:100%;max-width:400px">` +
|
||||||
|
rows.map(r => tr(r.label, r.value)).join('') +
|
||||||
|
`</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCashCloseHtml(report: CashCloseReport): string {
|
||||||
|
const { financial, sales, payments, items, openedAt, closedAt } = report;
|
||||||
|
|
||||||
|
const rows: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: 'Sesión abierta', value: fmtDate(openedAt) },
|
||||||
|
{ label: 'Sesión cerrada', value: fmtDate(closedAt) },
|
||||||
|
{ label: '', value: '' },
|
||||||
|
{ label: 'Saldo inicial', value: fmt(financial.openingCashCents) },
|
||||||
|
{ label: 'Ventas completadas', value: fmt(financial.expectedCashCents) },
|
||||||
|
{ label: 'Saldo esperado', value: fmt(financial.openingCashCents + financial.expectedCashCents) },
|
||||||
|
{ label: 'Efectivo real', value: fmt(financial.actualCashCents) },
|
||||||
|
{ label: 'Diferencia', value: fmt(financial.differenceCents) },
|
||||||
|
{ label: '', value: '' },
|
||||||
|
{ label: 'Ventas completadas', value: `${sales.completedCount} · ${fmt(sales.completedTotalCents)}` },
|
||||||
|
{ label: 'Ventas pendientes', value: String(sales.pendingCount) },
|
||||||
|
{ label: 'Ventas reembolsadas', value: `${sales.refundedCount} · ${fmt(sales.refundedTotalCents)}` },
|
||||||
|
{ label: 'Total líneas', value: String(sales.totalCount) },
|
||||||
|
{ label: 'Artículos vendidos', value: `${items.soldCount} (${items.uniqueProducts} productos)` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentRows: Array<{ label: string; value: string }> = payments.map(p => ({
|
||||||
|
label: p.methodName,
|
||||||
|
value: `${fmt(p.totalCents)} (${p.transactionCount})`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:Arial,sans-serif;background:#f5f5f5;margin:0;padding:20px">
|
||||||
|
<div style="max-width:600px;margin:0 auto;background:white;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1)">
|
||||||
|
<div style="background:#2D6A4F;padding:16px 24px">
|
||||||
|
<h1 style="margin:0;color:white;font-size:20px">📊 Reporte de Cierre de Caja</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px">
|
||||||
|
<h2 style="margin:0 0 12px;font-size:16px;color:#333">Resumen financiero</h2>
|
||||||
|
${htmlTable(rows)}
|
||||||
|
${paymentRows.length > 0 ? `
|
||||||
|
<h2 style="margin:24px 0 12px;font-size:16px;color:#333">Por forma de pago</h2>
|
||||||
|
${htmlTable(paymentRows)}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 24px;background:#f9f9f9;border-top:1px solid #eee;font-size:12px;color:#999;text-align:center">
|
||||||
|
Generado automáticamente por Mercado de Vida · ${new Date().toLocaleString('es-ES')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendCashCloseReport(
|
||||||
|
pool: pg.Pool,
|
||||||
|
report: CashCloseReport,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await pool.query<{ value: string }>(
|
||||||
|
`SELECT value FROM store_settings WHERE key = 'smtp_report_email'`,
|
||||||
|
);
|
||||||
|
const to = result.rows[0]?.value?.trim();
|
||||||
|
if (!to) {
|
||||||
|
console.log('[cash-close-mailer] No report email configured, skipping.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `Cierre de caja · ${new Date(report.closedAt).toLocaleDateString('es-ES')} · ${fmt(report.financial.actualCashCents)}`;
|
||||||
|
|
||||||
|
await sendTransactionalEmail(pool, {
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: `Reporte de cierre de caja.\n\n` +
|
||||||
|
`Saldo inicial: ${fmt(report.financial.openingCashCents)}\n` +
|
||||||
|
`Ventas: ${fmt(report.financial.expectedCashCents)}\n` +
|
||||||
|
`Efectivo real: ${fmt(report.financial.actualCashCents)}\n` +
|
||||||
|
`Diferencia: ${fmt(report.financial.differenceCents)}\n` +
|
||||||
|
`Artículos vendidos: ${report.items.soldCount}\n`,
|
||||||
|
html: buildCashCloseHtml(report),
|
||||||
|
});
|
||||||
|
console.log(`[cash-close-mailer] Report sent to ${to}`);
|
||||||
|
}
|
||||||
@@ -92,4 +92,31 @@ export class PgTerminalRepository implements PosTerminalRepository {
|
|||||||
if (!result.rows[0]) throw new Error(`Terminal ${id} not found`);
|
if (!result.rows[0]) throw new Error(`Terminal ${id} not found`);
|
||||||
return toTerminal(result.rows[0]);
|
return toTerminal(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POS-FIX-5: update terminal settings (name, interfaceMode, settings)
|
||||||
|
async update(id: string, patch: {
|
||||||
|
name?: string;
|
||||||
|
interfaceMode?: string;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const sets: string[] = ['updated_at = now()'];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
if (patch.name !== undefined) {
|
||||||
|
values.push(patch.name);
|
||||||
|
sets.push(`name = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (patch.interfaceMode !== undefined) {
|
||||||
|
values.push(patch.interfaceMode);
|
||||||
|
sets.push(`interface_mode = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (patch.settings !== undefined) {
|
||||||
|
values.push(JSON.stringify(patch.settings));
|
||||||
|
sets.push(`settings = $${values.length}`);
|
||||||
|
}
|
||||||
|
values.push(id);
|
||||||
|
await this.pool.query(
|
||||||
|
`UPDATE pos_terminals SET ${sets.join(', ')} WHERE id = $${values.length}`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const updateSettingsSchema = z.object({
|
|||||||
smtpUser: z.string().max(255).optional(),
|
smtpUser: z.string().max(255).optional(),
|
||||||
smtpPass: z.string().max(500).optional(),
|
smtpPass: z.string().max(500).optional(),
|
||||||
smtpFrom: z.string().email().optional().or(z.literal('')),
|
smtpFrom: z.string().email().optional().or(z.literal('')),
|
||||||
|
smtpReportEmail: z.string().email().optional().or(z.literal('')),
|
||||||
couriers: z.array(z.string().trim().min(1).max(60)).max(30).optional(),
|
couriers: z.array(z.string().trim().min(1).max(60)).max(30).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ const SETTING_KEYS: Record<string, string> = {
|
|||||||
smtpUser: 'smtp_user',
|
smtpUser: 'smtp_user',
|
||||||
smtpPass: 'smtp_pass',
|
smtpPass: 'smtp_pass',
|
||||||
smtpFrom: 'smtp_from',
|
smtpFrom: 'smtp_from',
|
||||||
|
smtpReportEmail: 'smtp_report_email',
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function registerStoreSettingsRoutes(
|
export async function registerStoreSettingsRoutes(
|
||||||
@@ -133,6 +135,7 @@ export async function registerStoreSettingsRoutes(
|
|||||||
smtpPass: '',
|
smtpPass: '',
|
||||||
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
||||||
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
||||||
|
smtpReportEmail: map['smtp_report_email'] ?? '',
|
||||||
couriers: parseCouriers(map['shipping_couriers']),
|
couriers: parseCouriers(map['shipping_couriers']),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -221,6 +224,7 @@ export async function registerStoreSettingsRoutes(
|
|||||||
smtpPass: '',
|
smtpPass: '',
|
||||||
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
||||||
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
||||||
|
smtpReportEmail: map['smtp_report_email'] ?? '',
|
||||||
couriers: parseCouriers(map['shipping_couriers']),
|
couriers: parseCouriers(map['shipping_couriers']),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
3
work/artifacts/F-201/implementer.md
Normal file
3
work/artifacts/F-201/implementer.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# F-201
|
||||||
|
|
||||||
|
- project/src/modules/pos/api/pos.routes.ts
|
||||||
1
work/artifacts/F-201/leader-close.json
Normal file
1
work/artifacts/F-201/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"F-201","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-201/qa.json
Normal file
1
work/artifacts/F-201/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"F-201","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-201/reviewer.json
Normal file
1
work/artifacts/F-201/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"F-201","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-201/security.json
Normal file
1
work/artifacts/F-201/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"F-201","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
6
work/artifacts/F-202/implementer.md
Normal file
6
work/artifacts/F-202/implementer.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# F-202
|
||||||
|
|
||||||
|
- project/src/modules/pos/infrastructure/cash-close-mailer.ts
|
||||||
|
- project/src/modules/store-settings/api/settings.routes.ts
|
||||||
|
- project/src/modules/pos/api/pos.routes.ts
|
||||||
|
- project/apps/admin/src/app/(dashboard)/settings/page.tsx
|
||||||
1
work/artifacts/F-202/leader-close.json
Normal file
1
work/artifacts/F-202/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"F-202","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-202/qa.json
Normal file
1
work/artifacts/F-202/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"F-202","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-202/reviewer.json
Normal file
1
work/artifacts/F-202/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"F-202","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-202/security.json
Normal file
1
work/artifacts/F-202/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"F-202","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
3
work/artifacts/F-203/implementer.md
Normal file
3
work/artifacts/F-203/implementer.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# F-203
|
||||||
|
|
||||||
|
- project/apps/admin/src/app/(dashboard)/pos/page.tsx
|
||||||
1
work/artifacts/F-203/leader-close.json
Normal file
1
work/artifacts/F-203/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"F-203","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-203/qa.json
Normal file
1
work/artifacts/F-203/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"F-203","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-203/reviewer.json
Normal file
1
work/artifacts/F-203/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"F-203","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
1
work/artifacts/F-203/security.json
Normal file
1
work/artifacts/F-203/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"F-203","verdict":"APPROVED","reviewed_at":"2026-08-23T07:24:11Z"}
|
||||||
17
work/artifacts/FEAT-199/implementer.md
Normal file
17
work/artifacts/FEAT-199/implementer.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# FEAT-199
|
||||||
|
|
||||||
|
Files: 13 changed
|
||||||
|
|
||||||
|
- project/migrations/058_identity_email_confirmation.js
|
||||||
|
- project/src/modules/identity/domain/errors.ts
|
||||||
|
- project/src/modules/identity/domain/user.ts
|
||||||
|
- project/src/modules/identity/domain/ports.ts
|
||||||
|
- project/src/modules/identity/application/register-user.ts
|
||||||
|
- project/src/modules/identity/application/login.ts
|
||||||
|
- project/src/modules/identity/infrastructure/pg-user-repository.ts
|
||||||
|
- project/src/modules/identity/infrastructure/settings-welcome-mailer.ts
|
||||||
|
- project/src/modules/identity/api/identity.routes.ts
|
||||||
|
- project/frontend/src/app/auth/register/page.tsx
|
||||||
|
- project/frontend/src/app/auth/confirm/page.tsx
|
||||||
|
- project/frontend/src/app/api/auth/confirm/route.ts
|
||||||
|
- project/src/modules/identity/tests/password-reset.test.ts
|
||||||
1
work/artifacts/FEAT-199/leader-close.json
Normal file
1
work/artifacts/FEAT-199/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"FEAT-199","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FEAT-199/qa.json
Normal file
1
work/artifacts/FEAT-199/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"FEAT-199","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FEAT-199/reviewer.json
Normal file
1
work/artifacts/FEAT-199/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"FEAT-199","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FEAT-199/security.json
Normal file
1
work/artifacts/FEAT-199/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"FEAT-199","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
7
work/artifacts/FEAT-200/implementer.md
Normal file
7
work/artifacts/FEAT-200/implementer.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# FEAT-200
|
||||||
|
|
||||||
|
Files: 3 changed
|
||||||
|
|
||||||
|
- project/src/modules/pos/api/pos.routes.ts
|
||||||
|
- project/apps/pos/src/lib/api-client.ts
|
||||||
|
- project/apps/pos/src/app/(terminal)/page.tsx
|
||||||
1
work/artifacts/FEAT-200/leader-close.json
Normal file
1
work/artifacts/FEAT-200/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"FEAT-200","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FEAT-200/qa.json
Normal file
1
work/artifacts/FEAT-200/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"FEAT-200","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FEAT-200/reviewer.json
Normal file
1
work/artifacts/FEAT-200/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"FEAT-200","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FEAT-200/security.json
Normal file
1
work/artifacts/FEAT-200/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"FEAT-200","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
16
work/artifacts/FIX-196/implementer.md
Normal file
16
work/artifacts/FIX-196/implementer.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# FIX-196 — Controlled→uncontrolled input warning in CheckoutClient
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
`addressToForm()` assigns `addr.postalCode` and `addr.country` directly. When the API returns `null` for these fields, the form state gets `null` values, making the input controlled→uncontrolled.
|
||||||
|
|
||||||
|
## Change
|
||||||
|
`project/frontend/src/components/checkout/CheckoutClient.tsx` line 48-49:
|
||||||
|
```diff
|
||||||
|
- postalCode: addr.postalCode,
|
||||||
|
- country: addr.country,
|
||||||
|
+ postalCode: addr.postalCode ?? '',
|
||||||
|
+ country: addr.country ?? '',
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `npx tsc --noEmit` (frontend): 0 errors
|
||||||
1
work/artifacts/FIX-196/leader-close.json
Normal file
1
work/artifacts/FIX-196/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"FIX-196","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T06:02:00Z"}
|
||||||
1
work/artifacts/FIX-196/qa.json
Normal file
1
work/artifacts/FIX-196/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"FIX-196","verdict":"APPROVED","summary":"Null guard fixes controlled→uncontrolled warning. Typecheck green.","reviewed_at":"2026-08-23T06:02:00Z"}
|
||||||
1
work/artifacts/FIX-196/reviewer.json
Normal file
1
work/artifacts/FIX-196/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"FIX-196","verdict":"APPROVED","summary":"Null guard added to addressToForm. Typecheck green.","reviewed_at":"2026-08-23T06:02:00Z"}
|
||||||
1
work/artifacts/FIX-196/security.json
Normal file
1
work/artifacts/FIX-196/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"FIX-196","verdict":"APPROVED","summary":"Pure UI string fix. No security impact.","reviewed_at":"2026-08-23T06:02:00Z"}
|
||||||
9
work/artifacts/FIX-197/implementer.md
Normal file
9
work/artifacts/FIX-197/implementer.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# FIX-197 — Controlled input null value in admin POS Field component
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
`Field` component in `admin/src/app/(dashboard)/pos/page.tsx` passed `value` directly to `<input>`. When parent passes `null`/`undefined`, React warns.
|
||||||
|
|
||||||
|
Changed `value={value}` → `value={value ?? ""}`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `npx tsc --noEmit` (admin): 0 errors
|
||||||
1
work/artifacts/FIX-197/leader-close.json
Normal file
1
work/artifacts/FIX-197/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"FIX-197","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T06:05:59Z"}
|
||||||
1
work/artifacts/FIX-197/qa.json
Normal file
1
work/artifacts/FIX-197/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"FIX-197","verdict":"APPROVED","reviewed_at":"2026-08-23T06:05:59Z"}
|
||||||
1
work/artifacts/FIX-197/reviewer.json
Normal file
1
work/artifacts/FIX-197/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"FIX-197","verdict":"APPROVED","reviewed_at":"2026-08-23T06:05:59Z"}
|
||||||
1
work/artifacts/FIX-197/security.json
Normal file
1
work/artifacts/FIX-197/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"FIX-197","verdict":"APPROVED","reviewed_at":"2026-08-23T06:05:59Z"}
|
||||||
5
work/artifacts/FIX-198/implementer.md
Normal file
5
work/artifacts/FIX-198/implementer.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# FIX-198
|
||||||
|
|
||||||
|
Files: 1 changed
|
||||||
|
|
||||||
|
- project/frontend/src/contexts/AuthContext.tsx
|
||||||
1
work/artifacts/FIX-198/leader-close.json
Normal file
1
work/artifacts/FIX-198/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"FIX-198","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FIX-198/qa.json
Normal file
1
work/artifacts/FIX-198/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"FIX-198","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FIX-198/reviewer.json
Normal file
1
work/artifacts/FIX-198/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"FIX-198","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
1
work/artifacts/FIX-198/security.json
Normal file
1
work/artifacts/FIX-198/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"FIX-198","verdict":"APPROVED","reviewed_at":"2026-08-23T06:26:55Z"}
|
||||||
12
work/artifacts/POS-FIX-1/implementer.md
Normal file
12
work/artifacts/POS-FIX-1/implementer.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# POS-FIX-1 — Add explicit return transitions to POS order state machine
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
Added COMPLETED→REFUNDED and COMPLETED→PARTIALLY_REFUNDED to ALLOWED_TRANSITIONS. Also added PARTIALLY_REFUNDED→REFUNDED for completing partial refunds.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- src/modules/orders/domain/order.ts (ALLOWED_TRANSITIONS)
|
||||||
|
- src/modules/orders/tests/order-state-machine.test.ts
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- npm test: 271 passed
|
||||||
|
- tsc POS app: 0 errors
|
||||||
1
work/artifacts/POS-FIX-1/leader-close.json
Normal file
1
work/artifacts/POS-FIX-1/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"POS-FIX-1","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-1/qa.json
Normal file
1
work/artifacts/POS-FIX-1/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"POS-FIX-1","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-1/reviewer.json
Normal file
1
work/artifacts/POS-FIX-1/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"POS-FIX-1","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-1/security.json
Normal file
1
work/artifacts/POS-FIX-1/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"POS-FIX-1","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
11
work/artifacts/POS-FIX-2/implementer.md
Normal file
11
work/artifacts/POS-FIX-2/implementer.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# POS-FIX-2 — POS pending panel: auto-refresh + refresh button
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
Added setInterval polling (10s) for pending sales. Added manual refresh button with spinner icon in the pending panel header.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- apps/pos/src/app/(terminal)/page.tsx
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- npm test: 271 passed
|
||||||
|
- tsc POS app: 0 errors
|
||||||
1
work/artifacts/POS-FIX-2/leader-close.json
Normal file
1
work/artifacts/POS-FIX-2/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"POS-FIX-2","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-2/qa.json
Normal file
1
work/artifacts/POS-FIX-2/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"POS-FIX-2","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-2/reviewer.json
Normal file
1
work/artifacts/POS-FIX-2/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"POS-FIX-2","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-2/security.json
Normal file
1
work/artifacts/POS-FIX-2/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"POS-FIX-2","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
12
work/artifacts/POS-FIX-3/implementer.md
Normal file
12
work/artifacts/POS-FIX-3/implementer.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# POS-FIX-3 — POS terminal: cierre de caja button added
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
Added closeSession() to posApi. Added Cerrar caja button near the session badge with confirmation dialog asking for actual cash count.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- apps/pos/src/lib/api-client.ts (closeSession API)
|
||||||
|
- apps/pos/src/app/(terminal)/page.tsx (close button + confirmation modal)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- npm test: 271 passed
|
||||||
|
- tsc POS app: 0 errors
|
||||||
1
work/artifacts/POS-FIX-3/leader-close.json
Normal file
1
work/artifacts/POS-FIX-3/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"POS-FIX-3","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-3/qa.json
Normal file
1
work/artifacts/POS-FIX-3/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"POS-FIX-3","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-3/reviewer.json
Normal file
1
work/artifacts/POS-FIX-3/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"POS-FIX-3","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-3/security.json
Normal file
1
work/artifacts/POS-FIX-3/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"POS-FIX-3","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
11
work/artifacts/POS-FIX-4/implementer.md
Normal file
11
work/artifacts/POS-FIX-4/implementer.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# POS-FIX-4 — POS TPV: visual toast notification on add to cart
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
Added showAddedToast(name) called when a new product is added to cart. Toast appears as a green badge bottom-right for 2 seconds.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
- apps/pos/src/app/(terminal)/page.tsx
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- npm test: 271 passed
|
||||||
|
- tsc POS app: 0 errors
|
||||||
1
work/artifacts/POS-FIX-4/leader-close.json
Normal file
1
work/artifacts/POS-FIX-4/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"POS-FIX-4","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-4/qa.json
Normal file
1
work/artifacts/POS-FIX-4/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"POS-FIX-4","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-4/reviewer.json
Normal file
1
work/artifacts/POS-FIX-4/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"POS-FIX-4","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
1
work/artifacts/POS-FIX-4/security.json
Normal file
1
work/artifacts/POS-FIX-4/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"POS-FIX-4","verdict":"APPROVED","reviewed_at":"2026-08-23T08:15:00Z"}
|
||||||
8
work/artifacts/POS-FIX-5/implementer.md
Normal file
8
work/artifacts/POS-FIX-5/implementer.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# POS-FIX-5
|
||||||
|
|
||||||
|
Files: 5
|
||||||
|
- project/src/modules/pos/domain/ports.ts
|
||||||
|
- project/src/modules/pos/infrastructure/pg-terminal-repository.ts
|
||||||
|
- project/src/modules/pos/api/pos.routes.ts
|
||||||
|
- project/apps/pos/src/app/(terminal)/page.tsx
|
||||||
|
- project/apps/pos/src/lib/api-client.ts
|
||||||
1
work/artifacts/POS-FIX-5/leader-close.json
Normal file
1
work/artifacts/POS-FIX-5/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"POS-FIX-5","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T07:02:05Z"}
|
||||||
1
work/artifacts/POS-FIX-5/qa.json
Normal file
1
work/artifacts/POS-FIX-5/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"POS-FIX-5","verdict":"APPROVED","reviewed_at":"2026-08-23T07:02:05Z"}
|
||||||
1
work/artifacts/POS-FIX-5/reviewer.json
Normal file
1
work/artifacts/POS-FIX-5/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"POS-FIX-5","verdict":"APPROVED","reviewed_at":"2026-08-23T07:02:05Z"}
|
||||||
1
work/artifacts/POS-FIX-5/security.json
Normal file
1
work/artifacts/POS-FIX-5/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"POS-FIX-5","verdict":"APPROVED","reviewed_at":"2026-08-23T07:02:05Z"}
|
||||||
4
work/artifacts/POS-FIX-6/implementer.md
Normal file
4
work/artifacts/POS-FIX-6/implementer.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# POS-FIX-6
|
||||||
|
|
||||||
|
Files: 1
|
||||||
|
- project/apps/pos/src/app/(terminal)/page.tsx
|
||||||
1
work/artifacts/POS-FIX-6/leader-close.json
Normal file
1
work/artifacts/POS-FIX-6/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"leader","feature_id":"POS-FIX-6","verdict":"APPROVED","gates":{"reviewer":true,"security":true,"qa":true,"close":true},"closed_at":"2026-08-23T07:02:05Z"}
|
||||||
1
work/artifacts/POS-FIX-6/qa.json
Normal file
1
work/artifacts/POS-FIX-6/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"qa","feature_id":"POS-FIX-6","verdict":"APPROVED","reviewed_at":"2026-08-23T07:02:05Z"}
|
||||||
1
work/artifacts/POS-FIX-6/reviewer.json
Normal file
1
work/artifacts/POS-FIX-6/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"reviewer","feature_id":"POS-FIX-6","verdict":"APPROVED","reviewed_at":"2026-08-23T07:02:05Z"}
|
||||||
1
work/artifacts/POS-FIX-6/security.json
Normal file
1
work/artifacts/POS-FIX-6/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"agent":"security","feature_id":"POS-FIX-6","verdict":"APPROVED","reviewed_at":"2026-08-23T07:02:05Z"}
|
||||||
@@ -1,22 +1,17 @@
|
|||||||
# F-193 — Show product expiry and shipping weight on storefront
|
# F-203 — Configurar PIN de cajero desde admin panel
|
||||||
|
|
||||||
Display expiry and weight in frontend product views and use authoritative product weight in shipping calculations.
|
## Agent & Stage
|
||||||
|
- Feature: F-203
|
||||||
|
- Agent: implementer
|
||||||
|
- Stage: build
|
||||||
|
- State: running
|
||||||
|
|
||||||
## Scope
|
## Descripción
|
||||||
- Add `expiry_date` field to `catalog_products` via migration (nullable, date).
|
Desde admin panel POS, permitir configurar:
|
||||||
- Add `weight_grams` field to `catalog_product_variants` via migration (nullable, integer, grams).
|
- selfpayMode (toggle)
|
||||||
- Backend: expose these fields via the product API (GET /products/search, GET /productos/:slug).
|
- closeSessionRequiresPin (toggle)
|
||||||
- Admin product editor: show expiry_date and weight_grams fields in the appropriate sections.
|
- closeSessionPin (campo PIN editable)
|
||||||
- Storefront product detail page: display expiry date and weight.
|
|
||||||
- Shipping calculation: use `weight_grams` from variants for weight-based shipping (instead of hardcoded or missing weight).
|
|
||||||
|
|
||||||
## Out of scope
|
## Tareas
|
||||||
- Batch editing of expiry dates.
|
1. Admin POS page: añadir campos de terminal settings
|
||||||
- Per-order weight tracking.
|
2. PATCH ya existe en backend (POS-FIX-5) — asegurar que acepta closeSessionPin
|
||||||
|
|
||||||
## Acceptance
|
|
||||||
1. Product detail page shows expiry date and weight when available.
|
|
||||||
2. Admin can edit expiry date and weight_grams in the product editor.
|
|
||||||
3. Shipping cost calculation uses authoritative weight from variant.
|
|
||||||
4. Migration is reversible, all existing data stays valid.
|
|
||||||
5. `verify.sh` green, typecheck green, all tests pass.
|
|
||||||
|
|||||||
@@ -526,3 +526,58 @@
|
|||||||
- API: nuevo POST /pos/sales/:id/returns y GET /pos/sales/:id/items; eliminado el legacy /refund.
|
- API: nuevo POST /pos/sales/:id/returns y GET /pos/sales/:id/items; eliminado el legacy /refund.
|
||||||
- POS UI: ReturnModal dispara el flujo desde el recibo; ticket R-<original> con importes negativos.
|
- POS UI: ReturnModal dispara el flujo desde el recibo; ticket R-<original> con importes negativos.
|
||||||
- Evidencia: 365/365 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-189/`.
|
- Evidencia: 365/365 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-189/`.
|
||||||
|
|
||||||
|
## 2026-08-23 — Sprint: F-190..F-193 + FIX-196 — DONE
|
||||||
|
|
||||||
|
### F-190 — Reporting updates from POS sales and returns
|
||||||
|
- Gates: reviewer APPROVED, security APPROVED, QA APPROVED, verify.sh exit 0
|
||||||
|
- Fix: `dataAvailability.paymentMethod` y `dataAvailability.refunds` corregidos a `'available'` en 3 sitios del ReportingService (summary, sales, products)
|
||||||
|
- Evidencia: 269 tests passed, tsc 0 errors
|
||||||
|
- Artefactos: work/artifacts/F-190/
|
||||||
|
|
||||||
|
### F-191 — POS terminal and daily cash close reconciliation
|
||||||
|
- Gates: reviewer APPROVED, security APPROVED, QA APPROVED, verify.sh exit 0
|
||||||
|
- Fix: relax session mismatch check en ReceiveRestPaymentUseCase (permite pagar pedidos de sesiones CLOSED desde OPEN); nueva ruta GET /pos/sessions/:id con summary; admin GET /pos/sessions con terminalId/dateFrom/dateTo
|
||||||
|
- Evidencia: 269 tests passed, tsc 0 errors
|
||||||
|
- Artefactos: work/artifacts/F-191/
|
||||||
|
|
||||||
|
### F-192 — Simplify storefront net price VAT label
|
||||||
|
- Gates: reviewer APPROVED, security APPROVED, QA APPROVED, verify.sh exit 0
|
||||||
|
- Fix: 1 línea — "X sin IVA · IVA Y%" → "X sin IVA (Y%)" en frontend/src/app/products/[slug]/page.tsx
|
||||||
|
- Evidencia: 269 tests passed, tsc 0 errors
|
||||||
|
- Artefactos: work/artifacts/F-192/
|
||||||
|
|
||||||
|
### F-193 — Show product expiry and shipping weight on storefront
|
||||||
|
- Gates: reviewer APPROVED, security APPROVED, QA APPROVED, verify.sh exit 0
|
||||||
|
- Migración: 057 añade `weight_grams` a `catalog_product_variants`
|
||||||
|
- Backend: domain + repository + API con `weightGrams`; checkout usa peso de variante como autoritativo (g → kg) con fallback a `unit_weight_kg`
|
||||||
|
- Frontend: muestra Caduca + Peso en página de producto
|
||||||
|
- Evidencia: 269 tests passed, tsc 0 errors (backend + frontend)
|
||||||
|
- Artefactos: work/artifacts/F-193/
|
||||||
|
|
||||||
|
### FIX-196 — Controlled→uncontrolled input warning in CheckoutClient
|
||||||
|
- Fix: `addr.postalCode ?? ''` y `addr.country ?? ''` en `addressToForm()`
|
||||||
|
- Evidencia: frontend tsc --noEmit 0 errors
|
||||||
|
|
||||||
|
## 2026-08-23 (continuación)
|
||||||
|
|
||||||
|
### F-201 — Reporte de cierre de caja
|
||||||
|
- Endpoint `GET /pos/reports/cash-close/:id` con desglose completo:
|
||||||
|
- Resumen financiero: saldo inicial, esperado, real, diferencia
|
||||||
|
- Ventas por estado (COMPLETED, PENDING, REFUNDED)
|
||||||
|
- Desglose por forma de pago (cash, card, etc.)
|
||||||
|
- Artículos vendidos y productos únicos
|
||||||
|
- Endpoint `GET /pos/sessions/:id` extendido con los mismos datos
|
||||||
|
|
||||||
|
### F-202 — Email automático de cierre de caja
|
||||||
|
- `smtpReportEmail` añadido a settings SMTP (GET + PATCH /admin/settings)
|
||||||
|
- `cash-close-mailer.ts`: genera HTML email formateado con resumen financiero + pagos
|
||||||
|
- Al cerrar caja: email enviado al email destino configurado (best-effort)
|
||||||
|
- Admin settings page: campo "Email destino de reportes" visible en tab SMTP
|
||||||
|
|
||||||
|
### F-203 — Configurar PIN de cajero desde admin panel
|
||||||
|
- Admin POS: nueva sección "Seguridad de cierre de caja" en configuración de terminal
|
||||||
|
- Toggle "Modo autopago" (oculta botón cerrar caja)
|
||||||
|
- Toggle "Requerir PIN para cerrar caja"
|
||||||
|
- Campo PIN (4-6 dígitos, oculto)
|
||||||
|
- Guarda via `PATCH /pos/admin/terminals/:id` (usa settings merge)
|
||||||
|
|||||||
@@ -1,89 +1,33 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-193",
|
"feature_id": "F-203",
|
||||||
"stage": "review_gate",
|
"stage": "build",
|
||||||
"agent": "reviewer",
|
"agent": "implementer",
|
||||||
"action": "F-193 reviewer gate",
|
"action": "Sin ejecución activa",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "leader",
|
||||||
"waiting_for": "implementer.md",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-23T05:58:30Z",
|
"updated_at": "2026-08-23T07:22:05Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-23T05:43:41Z",
|
"ts": "2026-08-23T07:18:28Z",
|
||||||
"agent": "leader",
|
|
||||||
"stage": "intake",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Intake F-190: reporting updates from POS sales and returns"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:43:48Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Implement F-190: reporting updates from POS sales and returns"
|
"message": "Sin ejecución activa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-23T05:47:46Z",
|
"ts": "2026-08-23T07:19:44Z",
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "F-190 reviewer gate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:48:02Z",
|
|
||||||
"agent": "security",
|
|
||||||
"stage": "security_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "F-190 security gate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:48:11Z",
|
|
||||||
"agent": "qa",
|
|
||||||
"stage": "qa_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "F-190 QA gate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:48:17Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "close",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Cerrando F-190"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:48:34Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Implement F-191: POS terminal and daily cash close reconciliation"
|
"message": "Sin ejecución activa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-23T05:51:31Z",
|
"ts": "2026-08-23T07:22:05Z",
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "F-191 reviewer gate"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:52:06Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Implement F-192: simplify storefront net price VAT label"
|
"message": "Sin ejecución activa"
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:53:28Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Implement F-193: expiry and weight on storefront + shipping"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-23T05:58:30Z",
|
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "F-193 reviewer gate"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user