diff --git a/project/VERSION b/project/VERSION index abd4105..a45be46 100644 --- a/project/VERSION +++ b/project/VERSION @@ -1 +1 @@ -0.2.4 +0.2.8 diff --git a/project/apps/admin/package-lock.json b/project/apps/admin/package-lock.json index 87b9494..0ec01f6 100644 --- a/project/apps/admin/package-lock.json +++ b/project/apps/admin/package-lock.json @@ -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", diff --git a/project/apps/admin/package.json b/project/apps/admin/package.json index b96f9bb..f56b512 100644 --- a/project/apps/admin/package.json +++ b/project/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@mercadodevida/admin", - "version": "0.2.4", + "version": "0.2.8", "private": true, "scripts": { "dev": "next dev --port 3001", diff --git a/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx b/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx index 3dfb6e5..92a3390 100644 --- a/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx @@ -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(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 (

- {isRefundEvent && 💸}{event.message} + {isRefundEvent && 💸}{formattedMessage}

{new Date(event.createdAt).toLocaleString('es-ES')} diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx index 2d934a8..2cbed66 100644 --- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx @@ -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); diff --git a/project/apps/admin/src/app/api/[...path]/route.ts b/project/apps/admin/src/app/api/[...path]/route.ts index ae4817e..bc53e84 100644 --- a/project/apps/admin/src/app/api/[...path]/route.ts +++ b/project/apps/admin/src/app/api/[...path]/route.ts @@ -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; + 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 { + 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 = { + 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 = { 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 { + 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 }, + ); } } diff --git a/project/apps/pos/package-lock.json b/project/apps/pos/package-lock.json index 6e52767..51693e7 100644 --- a/project/apps/pos/package-lock.json +++ b/project/apps/pos/package-lock.json @@ -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", diff --git a/project/apps/pos/package.json b/project/apps/pos/package.json index 8331e15..420a91f 100644 --- a/project/apps/pos/package.json +++ b/project/apps/pos/package.json @@ -1,6 +1,6 @@ { "name": "mercadodevida-pos", - "version": "0.2.4", + "version": "0.2.8", "private": true, "scripts": { "dev": "next dev --port 3002", diff --git a/project/apps/pos/src/app/(terminal)/page.tsx b/project/apps/pos/src/app/(terminal)/page.tsx index 1621d3d..7fc5025 100644 --- a/project/apps/pos/src/app/(terminal)/page.tsx +++ b/project/apps/pos/src/app/(terminal)/page.tsx @@ -1659,14 +1659,16 @@ export default function RegisterPage() { ))}

- + {!isSelfpayMode && ( + + )} @@ -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'} diff --git a/project/frontend/package-lock.json b/project/frontend/package-lock.json index ae377c9..c71632e 100644 --- a/project/frontend/package-lock.json +++ b/project/frontend/package-lock.json @@ -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", diff --git a/project/frontend/package.json b/project/frontend/package.json index 6158e74..6f113b2 100644 --- a/project/frontend/package.json +++ b/project/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.2.4", + "version": "0.2.8", "private": true, "scripts": { "dev": "next dev", diff --git a/project/frontend/src/app/products/[slug]/page.tsx b/project/frontend/src/app/products/[slug]/page.tsx index 63aa188..edddb97 100644 --- a/project/frontend/src/app/products/[slug]/page.tsx +++ b/project/frontend/src/app/products/[slug]/page.tsx @@ -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} /> ) : ( diff --git a/project/frontend/src/components/cart/AddToCartButton.tsx b/project/frontend/src/components/cart/AddToCartButton.tsx index d824f22..16ee3e1 100644 --- a/project/frontend/src/components/cart/AddToCartButton.tsx +++ b/project/frontend/src/components/cart/AddToCartButton.tsx @@ -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(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 ( {qty > 1 && (

Compra mínima: {qty} unidades.

diff --git a/project/frontend/src/components/cart/CartPageContent.tsx b/project/frontend/src/components/cart/CartPageContent.tsx index 47b7f7f..e782282 100644 --- a/project/frontend/src/components/cart/CartPageContent.tsx +++ b/project/frontend/src/components/cart/CartPageContent.tsx @@ -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(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 (
@@ -49,8 +78,18 @@ function CartItemRow({ item }: { item: CartItem }) { {item.quantity} @@ -62,6 +101,11 @@ function CartItemRow({ item }: { item: CartItem }) { Eliminar
+ {stockLabel && ( +

+ {stockLabel} +

+ )}
{/* Subtotal */} diff --git a/project/frontend/src/components/cart/ProductAddToCart.tsx b/project/frontend/src/components/cart/ProductAddToCart.tsx index d535fe0..622de96 100644 --- a/project/frontend/src/components/cart/ProductAddToCart.tsx +++ b/project/frontend/src/components/cart/ProductAddToCart.tsx @@ -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 (
diff --git a/project/migrations/064_orders_history_metadata.js b/project/migrations/064_orders_history_metadata.js new file mode 100644 index 0000000..1b3facb --- /dev/null +++ b/project/migrations/064_orders_history_metadata.js @@ -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 + `); +}; diff --git a/project/migrations/065_backfill_return_history_messages.js b/project/migrations/065_backfill_return_history_messages.js new file mode 100644 index 0000000..d001704 --- /dev/null +++ b/project/migrations/065_backfill_return_history_messages.js @@ -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 + `); +}; diff --git a/project/package-lock.json b/project/package-lock.json index 1a7752c..10474c8 100644 --- a/project/package-lock.json +++ b/project/package-lock.json @@ -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", diff --git a/project/package.json b/project/package.json index 7d32d5e..991e847 100644 --- a/project/package.json +++ b/project/package.json @@ -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", diff --git a/project/src/app/tests/cart.itest.ts b/project/src/app/tests/cart.itest.ts index c2964d1..9ed6a21 100644 --- a/project/src/app/tests/cart.itest.ts +++ b/project/src/app/tests/cart.itest.ts @@ -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 () => { diff --git a/project/src/modules/cart/application/cart-service.ts b/project/src/modules/cart/application/cart-service.ts index dda521d..f97e76a 100644 --- a/project/src/modules/cart/application/cart-service.ts +++ b/project/src/modules/cart/application/cart-service.ts @@ -19,8 +19,16 @@ export class CartService { async addItem(userId: string, input: CartItemInput): Promise { 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)); } diff --git a/project/src/modules/pos/api/pos.routes.ts b/project/src/modules/pos/api/pos.routes.ts index 20aacb0..4f3a278 100644 --- a/project/src/modules/pos/api/pos.routes.ts +++ b/project/src/modules/pos/api/pos.routes.ts @@ -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 { + 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`, diff --git a/project/src/modules/pos/application/apply-pos-return.ts b/project/src/modules/pos/application/apply-pos-return.ts index 19b82cd..1ceac58 100644 --- a/project/src/modules/pos/application/apply-pos-return.ts +++ b/project/src/modules/pos/application/apply-pos-return.ts @@ -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( - `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; +} + +/** + * 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})` : ''}`; +} diff --git a/project/storefront/package-lock.json b/project/storefront/package-lock.json index def7e07..19c7315 100644 --- a/project/storefront/package-lock.json +++ b/project/storefront/package-lock.json @@ -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", diff --git a/project/storefront/package.json b/project/storefront/package.json index 8e19ac1..d27c928 100644 --- a/project/storefront/package.json +++ b/project/storefront/package.json @@ -1,6 +1,6 @@ { "name": "mercadodevida-storefront", - "version": "0.2.4", + "version": "0.2.8", "private": true, "type": "module", "description": "mercadodevida customer storefront shell", diff --git a/project/storefront/src/components/add-to-cart.tsx b/project/storefront/src/components/add-to-cart.tsx index 3884755..630df7d 100644 --- a/project/storefront/src/components/add-to-cart.tsx +++ b/project/storefront/src/components/add-to-cart.tsx @@ -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" /> diff --git a/project/storefront/src/components/product-card.tsx b/project/storefront/src/components/product-card.tsx index 758f4b0..361c649 100644 --- a/project/storefront/src/components/product-card.tsx +++ b/project/storefront/src/components/product-card.tsx @@ -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" > -
+
{mainImage ? ( // Keep plain img for remote/local URL compatibility until image pipeline configuration exists. // eslint-disable-next-line @next/next/no-img-element diff --git a/work/current.md b/work/current.md index 2b21fb1..ae97bf3 100644 --- a/work/current.md +++ b/work/current.md @@ -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 --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. diff --git a/work/runtime-status.json b/work/runtime-status.json index c0fc6b8..a14b8ab 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -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" } ] }