fix(release): 0.2.8 harden admin proxy, cart stock caps, selfpay and refund timeline

- admin proxy: 25s timeout, body guards, structured failure logs
- cart: addItem enforces stock cap (409 INSUFFICIENT_STOCK), UI clamps qty
- tpv selfpay: hide sidebar/discounts/save-pending, rename button, receipt-settings 400 fix
- pos admin: quick products slot count aligned to 8
- returns: human-readable history message + metadata jsonb (migrations 064-065) + admin fallback formatter
- storefront: product card white background
- product page: remove duplicate stock label under add-to-cart button
This commit is contained in:
Deploy
2026-08-25 23:41:29 +02:00
parent b6adf681d1
commit b6af852b54
29 changed files with 671 additions and 279 deletions

View File

@@ -1 +1 @@
0.2.4
0.2.8

View File

@@ -1,12 +1,12 @@
{
"name": "@mercadodevida/admin",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mercadodevida/admin",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"@lexical/history": "^0.49.0",
"@lexical/html": "^0.49.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@mercadodevida/admin",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"scripts": {
"dev": "next dev --port 3001",

View File

@@ -78,6 +78,35 @@ function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
/**
* F-138: legacy return events were stored as raw JSON. If the migration
* hasn't caught up (or didn't run on a particular row) we still want the UI
* to show something readable instead of dumping the payload. Falls back to
* the original string when the value isn't JSON.
*/
function formatRefundMessage(raw: string): string {
const trimmed = raw?.trim() ?? '';
if (!trimmed.startsWith('{')) return raw;
try {
const payload = JSON.parse(trimmed) as {
refundedCents?: number;
status?: 'refund' | 'partial_refund';
lines?: Array<{ returnedQuantity?: number }>;
reason?: string;
};
const euros = ((payload.refundedCents ?? 0) / 100).toFixed(2);
const action = payload.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
const lineCount = Array.isArray(payload.lines)
? payload.lines.reduce((sum, line) => sum + (line.returnedQuantity ?? 0), 0)
: 0;
const reason = payload.reason?.trim();
const detail = lineCount > 0 ? ` (${lineCount} uds.)` : '';
return `${action} de ${euros}${detail}${reason ? `${reason}` : ''}`;
} catch {
return raw;
}
}
export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>();
const [order, setOrder] = useState<Order | null>(null);
@@ -710,6 +739,9 @@ export default function OrderDetailPage() {
// ORDERS-FIX: detectar refunds para mostrar de forma más legible
const isRefund = /refund|reembolso|devolu/i.test(event.message);
const isRefundEvent = event.eventType === 'REFUND' || isRefund;
const formattedMessage = isRefundEvent
? formatRefundMessage(event.message)
: event.message;
return (
<div key={event.id} className="flex gap-3">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
@@ -718,7 +750,7 @@ export default function OrderDetailPage() {
}`} />
<div className="min-w-0">
<p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}>
{isRefundEvent && <span className="mr-1">💸</span>}{event.message}
{isRefundEvent && <span className="mr-1">💸</span>}{formattedMessage}
</p>
<p className="text-xs text-gray-400">
{new Date(event.createdAt).toLocaleString('es-ES')}

View File

@@ -199,7 +199,7 @@ export default function PosAdminPage() {
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
const configured = terminal.settings?.quickProductVariantIds ?? [];
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null));
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
setTouchMessage('');
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);

View File

@@ -2,126 +2,207 @@ import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Catch-all proxy: forwards ALL requests to the backend API.
* This avoids CORS preflight issues since requests stay within the
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
*
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
* so they are NOT served by this handler.
*/
export async function GET(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
headers: { Cookie: cookies },
});
if (path === 'admin/logs/stream') {
// Pipe SSE directly: Next.js App Router supports ReadableStream passthrough.
// X-Accel-Buffering: no tells any intermediate proxy (nginx) not to buffer.
const headers = new Headers();
headers.set('Content-Type', backendRes.headers.get('content-type') ?? 'text/event-stream');
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
headers.set('X-Accel-Buffering', 'no');
// Do NOT set Connection: keep-alive — it is HTTP/1.1 default for persistent
// connections and can confuse proxies that do not expect streaming.
return new Response(backendRes.body, {
status: backendRes.status,
headers,
});
}
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
const PROXY_TIMEOUT_MS = 25_000;
const REQUEST_ID_HEADER = 'x-request-id';
interface ProxyInit {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
/** Raw body, or `null` when there is no body to forward. */
body: string | null;
headers: Record<string, string>;
requestId: string;
}
export async function POST(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
interface ProxyResult {
status: number;
headers: Headers;
body: ReadableStream | null;
}
async function proxyToBackend(
request: NextRequest,
init: ProxyInit,
): Promise<ProxyResult> {
const path = request.nextUrl.pathname.replace('/api/', '');
const search = request.nextUrl.search;
const url = `${API}/${path}${search}`;
// Forward only the cookies the proxy knows are safe to relay. We deliberately
// do NOT pass `content-length` (Node fetch sets it from `body`), and we keep
// `content-type` only when we actually have a body to send — otherwise some
// upstreams reject the request or return 502 on bodyless PATCH/POST calls.
const headers: Record<string, string> = {
Cookie: init.headers.Cookie ?? '',
[REQUEST_ID_HEADER]: init.requestId,
};
if (init.body !== null) {
headers['Content-Type'] = 'application/json';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Cookie: cookies };
if (body) headers['Content-Type'] = 'application/json';
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'POST',
const backendRes = await fetch(url, {
method: init.method,
headers,
...(body ? { body } : {}),
body: init.body,
signal: controller.signal,
});
const responseHeaders = new Headers();
const contentType = backendRes.headers.get('content-type');
if (contentType) responseHeaders.set('Content-Type', contentType);
const setCookie = backendRes.headers.get('set-cookie');
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
if (setCookie) {
resp.headers.set(
responseHeaders.set(
'Set-Cookie',
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
);
}
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PATCH(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PUT(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function DELETE(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'DELETE',
headers: { Cookie: cookies },
});
// Reenviar la respuesta del backend tal cual: el backend puede devolver
// 204 No Content (sin body) en borrados exitosos, y forzar un JSON con
// status 204 es HTTP inválido (F-127).
return new Response(backendRes.body, {
return {
status: backendRes.status,
statusText: backendRes.statusText,
headers: { 'Content-Length': backendRes.headers.get('content-length') ?? '0' },
});
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
headers: responseHeaders,
body: backendRes.body,
};
} finally {
clearTimeout(timeout);
}
}
/**
* Reads the incoming request body safely. Returns `null` for bodyless methods
* (GET/DELETE) and an empty string when the body is empty.
*/
async function safeReadBody(request: NextRequest): Promise<string | null> {
if (request.method === 'GET' || request.method === 'DELETE' || request.method === 'HEAD') {
return null;
}
try {
return await request.text();
} catch (error) {
console.warn('[proxy] failed to read body', { message: (error as Error)?.message });
return null;
}
}
function getRequestId(request: NextRequest): string {
return (
request.headers.get(REQUEST_ID_HEADER) ??
request.headers.get('x-vercel-id') ??
`adm-${Math.random().toString(36).slice(2, 10)}`
);
}
function logProxyFailure(stage: string, request: NextRequest, requestId: string, error: unknown) {
console.warn('[proxy] failure', {
stage,
requestId,
method: request.method,
target: request.nextUrl.pathname,
message: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : 'unknown',
});
}
export async function GET(request: NextRequest) {
const requestId = getRequestId(request);
try {
const result = await proxyToBackend(request, {
method: 'GET',
body: null,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
if (request.nextUrl.pathname === 'admin/logs/stream') {
result.headers.set('Content-Type', result.headers.get('content-type') ?? 'text/event-stream');
result.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
result.headers.set('X-Accel-Buffering', 'no');
}
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('get', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function POST(request: NextRequest) {
const requestId = getRequestId(request);
try {
const body = await safeReadBody(request);
const result = await proxyToBackend(request, {
method: 'POST',
body,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('post', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function PATCH(request: NextRequest) {
const requestId = getRequestId(request);
try {
const body = await safeReadBody(request);
const result = await proxyToBackend(request, {
method: 'PATCH',
body,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('patch', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function PUT(request: NextRequest) {
const requestId = getRequestId(request);
try {
const body = await safeReadBody(request);
const result = await proxyToBackend(request, {
method: 'PUT',
body,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('put', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function DELETE(request: NextRequest) {
const requestId = getRequestId(request);
try {
const result = await proxyToBackend(request, {
method: 'DELETE',
body: null,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('delete', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-pos",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-pos",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"next": "^16.3.1",
"react": "^19.2.8",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-pos",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"scripts": {
"dev": "next dev --port 3002",

View File

@@ -1659,14 +1659,16 @@ export default function RegisterPage() {
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void parkSale()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Guardando…' : 'Guardar pendiente'}
</button>
{!isSelfpayMode && (
<button
type="button"
onClick={() => void parkSale()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Guardando…' : 'Guardar pendiente'}
</button>
)}
<button
type="button"
onClick={() => void confirmSale()}
@@ -1677,7 +1679,9 @@ export default function RegisterPage() {
paidCents <= 0 ||
paidCents > totals.total
}
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
className={`min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40 ${
isSelfpayMode ? 'col-span-2' : ''
}`}
>
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
</button>
@@ -1688,7 +1692,7 @@ export default function RegisterPage() {
disabled={processing || cart.length === 0}
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
>
Vaciar caja
{isSelfpayMode ? '🧹 Limpiar Ticket' : 'Vaciar caja'}
</button>
</aside>

View File

@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",

View File

@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -201,6 +201,7 @@ export default async function ProductPage({ params }: Props) {
priceCents={grossCents}
imageUrl={product.images?.[0]?.url}
available={true}
availableQuantity={stock.availableQuantity}
minPurchaseQty={product.minPurchaseQty ?? 1}
/>
) : (

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useCart } from '@/contexts/CartContext';
import { fetchStockAvailability } from '@/lib/api';
interface Props {
variantId: string;
@@ -10,24 +11,48 @@ interface Props {
imageUrl?: string;
available?: boolean;
minPurchaseQty?: number;
/** F-138: max units available right now. Used to cap the add-to-cart action. */
availableQuantity?: number;
className?: string;
}
export default function AddToCartButton({
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, className = '',
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, availableQuantity, className = '',
}: Props) {
const { addItem, itemCount } = useCart();
const { addItem, items } = useCart();
const [added, setAdded] = useState(false);
const [liveStock, setLiveStock] = useState<number | null>(availableQuantity ?? null);
const qty = Math.max(1, minPurchaseQty);
// Refresh stock so the button reflects reality (especially on stale tabs).
useEffect(() => {
let cancelled = false;
fetchStockAvailability(variantId)
.then((s) => {
if (!cancelled) setLiveStock(s.availableQuantity);
})
.catch(() => {
if (!cancelled) setLiveStock(null);
});
return () => {
cancelled = true;
};
}, [variantId]);
// Account for whatever the user already has in the cart for this variant.
const inCart = items.find((it) => it.variantId === variantId)?.quantity ?? 0;
const stockCap = liveStock ?? null;
const remaining = stockCap === null ? null : Math.max(0, stockCap - inCart);
const disabled = !available || (remaining !== null && remaining < qty);
const handleAdd = () => {
if (!available) return;
if (disabled) return;
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
if (!available) {
if (!available || remaining === 0) {
return (
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
Agotado
@@ -47,9 +72,10 @@ export default function AddToCartButton({
<div>
<button
onClick={handleAdd}
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
disabled={disabled}
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg disabled:bg-gray-300 disabled:cursor-not-allowed ${className}`}
>
Añadir al carrito{qty > 1 ? ` (${qty} uds.)` : ''}
{disabled && remaining !== null ? `Solo ${remaining} uds.` : `Añadir al carrito${qty > 1 ? ` (${qty} uds.)` : ''}`}
</button>
{qty > 1 && (
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>

View File

@@ -1,7 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useCart, type CartItem } from '@/contexts/CartContext';
import { fetchStockAvailability } from '@/lib/api';
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
@@ -9,6 +11,33 @@ function formatPrice(cents: number) {
function CartItemRow({ item }: { item: CartItem }) {
const { removeItem, changeQuantity } = useCart();
const [stockMax, setStockMax] = useState<number | null>(null);
// F-138: cap the + button to actual stock so the buyer can never load more
// units than the SKU allows.
useEffect(() => {
let cancelled = false;
fetchStockAvailability(item.variantId)
.then((s) => {
if (!cancelled) setStockMax(s.availableQuantity);
})
.catch(() => {
if (!cancelled) setStockMax(null);
});
return () => {
cancelled = true;
};
}, [item.variantId]);
const atStockMax = stockMax !== null && item.quantity >= stockMax;
const stockLabel =
stockMax === null
? null
: stockMax === 0
? 'Sin stock'
: stockMax <= 3
? `Quedan ${stockMax} uds.`
: `Stock: ${stockMax} uds.`;
return (
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
@@ -49,8 +78,18 @@ function CartItemRow({ item }: { item: CartItem }) {
</button>
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
<button
onClick={() => changeQuantity(item.variantId, item.quantity + 1)}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
onClick={() => {
if (stockMax === null) {
changeQuantity(item.variantId, item.quantity + 1);
return;
}
// Hard cap: never go above stockMax and never below 1.
const next = Math.min(stockMax, item.quantity + 1);
if (next > item.quantity) changeQuantity(item.variantId, next);
}}
disabled={atStockMax}
title={atStockMax ? `Solo hay ${stockMax} uds. disponibles` : undefined}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
+
</button>
@@ -62,6 +101,11 @@ function CartItemRow({ item }: { item: CartItem }) {
Eliminar
</button>
</div>
{stockLabel && (
<p className={`mt-1 text-xs ${atStockMax ? 'text-amber-600' : 'text-gray-500'}`}>
{stockLabel}
</p>
)}
</div>
{/* Subtotal */}

View File

@@ -1,5 +1,4 @@
'use client';
import { useState } from 'react';
import AddToCartButton from './AddToCartButton';
interface Props {
@@ -9,10 +8,12 @@ interface Props {
priceCents: number;
imageUrl?: string;
available: boolean;
/** F-138: available stock for the variant. */
availableQuantity?: number;
minPurchaseQty?: number;
}
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, minPurchaseQty }: Props) {
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, availableQuantity, minPurchaseQty }: Props) {
return (
<div className="mt-6">
<AddToCartButton
@@ -22,6 +23,7 @@ export default function ProductAddToCart({ variantId, productId, productName, pr
priceCents={priceCents}
imageUrl={imageUrl}
available={available}
availableQuantity={availableQuantity}
minPurchaseQty={minPurchaseQty}
className="w-full sm:w-auto"
/>

View File

@@ -0,0 +1,20 @@
/**
* F-138: orders_order_history.metadata holds the structured payload for
* machine-readable events (returns, refunds, etc.) so we can keep the
* human-visible `message` column short and friendly while preserving all the
* detail that internal tooling needs.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE orders_order_history
ADD COLUMN IF NOT EXISTS metadata jsonb NULL
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE orders_order_history
DROP COLUMN IF EXISTS metadata
`);
};

View File

@@ -0,0 +1,65 @@
/**
* F-138 backfill: convert any legacy `orders_order_history` refund entries that
* were stored as raw JSON `message` strings into the new human-readable format
* introduced by `apply-pos-return.ts`. We also move the original payload into
* the new `metadata` column so internal tooling still has the structured data.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
WITH parsed AS (
SELECT id,
(message::jsonb) AS payload,
(
CASE (message::jsonb->>'status')
WHEN 'refund' THEN 'Devolución total'
ELSE 'Devolución parcial'
END
) AS action
FROM orders_order_history
WHERE event_type = 'RETURNED'
AND message ~ '^\\s*\\{'
),
formatted AS (
SELECT id,
action || ' de ' ||
(round(((payload->>'refundedCents')::numeric) / 100)::numeric(12,2)::text) ||
' €' ||
COALESCE(
(SELECT ' — ' || string_agg(
((l->>'returnedQuantity')::int)::text || ' × ' ||
COALESCE(oi.name, 'Artículo'),
', '
ORDER BY ord)
FROM jsonb_array_elements(payload->'lines') WITH ORDINALITY AS x(l, ord)
LEFT JOIN orders_items oi ON oi.id = (l->>'orderItemId')::uuid
WHERE (l->>'returnedQuantity')::int > 0
),
''
) ||
CASE
WHEN NULLIF(payload->>'reason', '') IS NOT NULL
THEN ' (motivo: ' || (payload->>'reason') || ')'
ELSE ''
END AS readable
FROM parsed
)
UPDATE orders_order_history h
SET message = f.readable,
metadata = h.message::jsonb
FROM formatted f
WHERE h.id = f.id
`);
};
export const down = (pgm) => {
// Best-effort: restore the JSON message from metadata. We can't know whether
// a row was originally a JSON message or already human-readable, so the
// down migration just clears the column.
pgm.sql(`
UPDATE orders_order_history
SET message = metadata::text,
metadata = NULL
WHERE event_type = 'RETURNED' AND metadata IS NOT NULL
`);
};

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-backend",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-backend",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-backend",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"type": "module",
"description": "mercadodevida vNext backend - modular monolith skeleton",

View File

@@ -105,28 +105,52 @@ describe.skipIf(!hasDb)('cart flows (real PostgreSQL)', () => {
});
});
it('flags cart item unavailable when variant is out of stock (AC2)', async () => {
it('rejects add when stock is zero (AC2 — F-138)', async () => {
const productId = randomUUID();
const variantId = randomUUID();
await setPrice(variantId, 500);
await setStock(variantId, 0);
await app.inject({
const response = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1 },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
const cart = await app.inject({
method: 'GET',
url: '/cart',
cookies: { [SESSION_COOKIE_NAME]: cookie },
});
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find(
(entry) => entry.variantId === variantId,
);
expect(item).toMatchObject({ available: false });
const items = cart.json().items as Array<{ variantId: string }>;
expect(items.find((entry) => entry.variantId === variantId)).toBeUndefined();
});
it('rejects add when total quantity exceeds stock (AC2b — F-138)', async () => {
const productId = randomUUID();
const variantId = randomUUID();
await setPrice(variantId, 500);
await setStock(variantId, 3);
const first = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 2 },
});
expect(first.statusCode).toBe(201);
const oversell = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 2 },
});
expect(oversell.statusCode).toBe(409);
expect(oversell.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
});
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {

View File

@@ -19,8 +19,16 @@ export class CartService {
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
ensurePositiveQuantity(input.quantity);
// Keep unavailable lines visible in the cart so the customer can remove or
// replace them; checkout remains the authoritative stock gate.
// F-138: stock cap is enforced on every cart mutation. The previous
// behaviour ("keep unavailable lines visible so the buyer can remove
// them") allowed overselling in the online storefront: customers could
// load 50 units of a 3-unit SKU into the cart and only failed at checkout.
// Cap the resulting quantity to whatever is actually in stock and surface
// the available amount to the caller via the standard error.
const cart = await this.carts.getOrCreate(userId);
const existing = cart.items.find((item) => item.variantId === input.variantId);
const requested = (existing?.quantity ?? 0) + input.quantity;
await this.assertStockAvailable(input.variantId, requested);
return this.toView(await this.carts.addItem(userId, input));
}

View File

@@ -57,6 +57,31 @@ async function assertReceiptAccess(
}
}
/**
* Resolves which store the admin POS settings page should target.
*
* Accepts the explicit `storeId` query value when present and well-formed;
* otherwise falls back to the first active store. This keeps the admin
* dashboard recoverable when the user lands on the page before the store
* dropdown is hydrated or the dropdown value is somehow invalid (F-139).
*/
async function resolveStoreIdForReceipt(
pool: pg.Pool,
rawStoreId: string | undefined,
): Promise<string> {
if (rawStoreId && storeIdSchema.safeParse(rawStoreId).success) {
return rawStoreId;
}
const fallback = await pool.query<{ id: string }>(
`SELECT id FROM pos_stores WHERE active = true ORDER BY created_at ASC LIMIT 1`,
);
const id = fallback.rows[0]?.id;
if (!id) {
throw new AppError(404, 'NO_ACTIVE_STORE', 'No hay tiendas activas configuradas');
}
return id;
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps;
@@ -1208,8 +1233,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
summary: 'Get company and receipt numbering settings',
querystring: {
type: 'object',
required: ['storeId'],
properties: { storeId: { type: 'string', format: 'uuid' } },
properties: { storeId: { type: 'string' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
@@ -1217,7 +1241,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query);
const rawStoreId = (request.query as { storeId?: string }).storeId;
// F-139: the admin POS page can land before the store dropdown has a
// selection. Fall back to the first active store so the UI never sees a
// 400 caused by an empty querystring.
const storeId = await resolveStoreIdForReceipt(pool, rawStoreId);
await pool.query(
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
ON CONFLICT (store_id) DO NOTHING`,

View File

@@ -15,6 +15,7 @@ interface OrderRow {
interface OrderItemRow {
id: string;
name: string;
quantity: number;
returned_quantity: number;
is_free_item: boolean;
@@ -131,7 +132,7 @@ export class ApplyPosReturnUseCase {
}
const itemRows = await client.query<OrderItemRow>(
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
`SELECT id, name, quantity, returned_quantity, is_free_item, variant_id,
unit_price_cents, discount_cents, tax_cents
FROM orders_items
WHERE order_id = $1
@@ -315,10 +316,18 @@ export class ApplyPosReturnUseCase {
],
);
await client.query(
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`,
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email, metadata)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3), $4)`,
[
input.orderId,
formatReturnMessage({
refundedCents: totalRefundCents,
status: reportingStatus,
lines: input.lines,
reason: input.reason,
items: itemsById,
}),
input.userId,
JSON.stringify({
returnId,
refundedCents: totalRefundCents,
@@ -326,7 +335,6 @@ export class ApplyPosReturnUseCase {
lines: input.lines,
reason: input.reason,
}),
input.userId,
],
);
@@ -366,3 +374,38 @@ export class ApplyPosReturnUseCase {
}
}
}
interface FormatReturnMessageInput {
refundedCents: number;
status: 'refund' | 'partial_refund';
lines: ReadonlyArray<{ orderItemId: string; returnedQuantity: number }>;
reason: string;
items: ReadonlyMap<string, OrderItemRow>;
}
/**
* Renders a refund event as a short, human-readable line for the order
* timeline. We deliberately do NOT dump the raw payload here: customers and
* back-office staff see `event.message` and the JSON was unreadable.
* Structured fields are still stored on `orders_order_history.metadata` so
* support tooling can read them when needed.
*/
function formatReturnMessage(input: FormatReturnMessageInput): string {
const euros = (input.refundedCents / 100).toFixed(2);
const action = input.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
const lineDescriptions = input.lines
.filter((line) => line.returnedQuantity > 0)
.map((line) => {
const item = input.items.get(line.orderItemId);
const name = item?.name ?? 'Artículo';
return `${line.returnedQuantity} × ${name}`;
});
const linesSummary =
lineDescriptions.length === 0
? ''
: `${lineDescriptions.slice(0, 3).join(', ')}${
lineDescriptions.length > 3 ? ` y ${lineDescriptions.length - 3} más` : ''
}`;
const reason = input.reason?.trim();
return `${action} de ${euros}${linesSummary}${reason ? ` (motivo: ${reason})` : ''}`;
}

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-storefront",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-storefront",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"@tailwindcss/postcss": "^4.1.17",
"next": "^16.0.5",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-storefront",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"type": "module",
"description": "mercadodevida customer storefront shell",

View File

@@ -62,7 +62,7 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
const existing = items.find((it) => it.variantId === productId);
const newQty = existing ? existing.quantity + qty : qty;
if (newQty > stock) {
setError(`Solo hay ${stock} unidades disponibles.`);
setError(`Solo hay ${stock} unidades disponibles en total (ya tienes ${existing?.quantity ?? 0} en el carrito).`);
return;
}
const next = existing
@@ -93,7 +93,16 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
min={1}
max={stock}
value={qty}
onChange={(e) => setQty(Math.max(1, parseInt(e.target.value, 10) || 1))}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
if (!Number.isFinite(parsed)) {
setQty(1);
return;
}
// F-138: never let the buyer enter a quantity outside [1, stock].
const clamped = Math.max(1, Math.min(stock, parsed));
setQty(clamped);
}}
disabled={disabled}
className="w-24 rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-700 outline-none disabled:opacity-50"
/>

View File

@@ -8,7 +8,7 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
href={product.url}
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
>
<div className="flex w-full max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
<div className="flex w-full max-h-72 items-center justify-center bg-white text-sm text-emerald-900 overflow-hidden">
{mainImage ? (
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
// eslint-disable-next-line @next/next/no-img-element

View File

@@ -1,16 +1,21 @@
# Estado Actual
## Ninguna feature activa
## Sesión anterior completada: 2026-08-25
**Última feature cerrada:** `FRONTEND-UI-FIXES2` (ya estaba `done` en backlog)
### Features cerradas
- TPV-FIXES (3/3 bugs) ✅
- TICKET-LOGO ✅
- ORDERS-FIX ✅
Para iniciar una nueva feature:
1. Seleccionar una feature `pending` de `backlog/features.json`
2. Actualizar estado con `python3 scripts/agent_status.py set --feature-id <id> --stage intake --agent leader`
3. Continuar con `harness/workflow.stages.yml`
### Pendientes
- **INVENTORY-OPT**: Inventory optimization (pagination, queries, filters for 10k+ products)
- **SHIPPING-ZONES**: Restrict Balearic and Canary islands, add continental...
---
### Issue pendiente de resolver
- **TPV stock**: Tema de stock con items convertidos a free items necesita revisión más profunda
## Resumen backlog
- Total features: 344
- Features pendientes de iniciar: verificar con `rg '"status": "pending"' backlog/features.json | wc -l`
## Para iniciar próxima sesión
```bash
./scripts/pi_orquestra.sh
```
Seleccionar INVENTORY-OPT o SHIPPING-ZONES del backlog.

View File

@@ -1,152 +1,152 @@
{
"feature_id": "TPV-FIXES",
"stage": "review_gate",
"agent": "reviewer",
"action": "Fixing favicon 404, cashier label, and pos/sales 400 error",
"state": "done",
"feature_id": "POS-RECEIPT-QUICK-FIXES",
"stage": "build",
"agent": "implementer",
"action": "Fix receipt-settings 400 and quick products save",
"state": "running",
"next_agent": null,
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-25T04:47:13Z",
"updated_at": "2026-08-25T21:03:29Z",
"timeline": [
{
"ts": "2026-08-25T04:27:58Z",
"agent": "implementer",
"stage": "build",
"state": "idle",
"message": "Estado actualizado"
},
{
"ts": "2026-08-25T04:28:09Z",
"agent": "implementer",
"stage": "build",
"state": "idle",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:28:15Z",
"ts": "2026-08-25T19:46:39Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Rebuild dist for awaiting payment notification fix"
},
{
"ts": "2026-08-25T04:30:08Z",
"ts": "2026-08-25T19:52:55Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Redeploy local prod stack after awaiting-payment fix"
},
{
"ts": "2026-08-25T04:30:24Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:30:35Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:30:44Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:31:04Z",
"agent": "leader",
"stage": "intake",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:31:58Z",
"ts": "2026-08-25T19:53:50Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Fix monolith restart to stop old deployments before starting latest"
},
{
"ts": "2026-08-25T04:35:08Z",
"ts": "2026-08-25T19:59:53Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Fix monolith restart to remove old deployments and run latest version"
},
{
"ts": "2026-08-25T04:35:09Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:35:18Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:35:26Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:35:43Z",
"agent": "leader",
"stage": "intake",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:37:19Z",
"ts": "2026-08-25T20:00:40Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Add semantic versioning and conventional commit enforcement to Orquestra"
},
{
"ts": "2026-08-25T04:37:57Z",
"ts": "2026-08-25T20:04:01Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Add semantic versioning and conventional commit enforcement to Orquestra"
},
{
"ts": "2026-08-25T04:37:57Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:45:38Z",
"ts": "2026-08-25T20:04:58Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Add settings about section with service versions and server info"
},
{
"ts": "2026-08-25T04:47:13Z",
"ts": "2026-08-25T20:08:32Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Add settings about section with service versions and server info"
},
{
"ts": "2026-08-25T04:47:13Z",
"agent": "reviewer",
"stage": "review_gate",
"ts": "2026-08-25T20:12:55Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
},
{
"ts": "2026-08-25T20:16:24Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
},
{
"ts": "2026-08-25T20:20:00Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix reporting trend chart height and closures report display"
},
{
"ts": "2026-08-25T20:24:31Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fix reporting trend chart height and closures report display"
},
{
"ts": "2026-08-25T20:26:16Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
},
{
"ts": "2026-08-25T20:30:04Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
},
{
"ts": "2026-08-25T20:36:53Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Remove duplicate ticket print header and force white print background"
},
{
"ts": "2026-08-25T20:39:16Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Remove duplicate ticket print header and force white print background"
},
{
"ts": "2026-08-25T20:39:16Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Diagnose PATCH /api/products/[id] 502 Bad Gateway"
},
{
"ts": "2026-08-25T20:50:29Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502"
},
{
"ts": "2026-08-25T20:54:10Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502, selfpay button rename"
},
{
"ts": "2026-08-25T21:03:29Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix receipt-settings 400 and quick products save"
}
]
}