Compare commits

...

60 Commits

Author SHA1 Message Date
Deploy
dcd1b2fc41 chore(dash-remove-variants-card): dashboard: remove variants KPI cards (Sin stock + Alertas) 2026-08-27 23:28:43 +02:00
Deploy
1376d3e4f0 feat(notif-inventory-alerts): admin bell: add inventory alerts (low stock, out of stock, expired/expiring, low margin) 2026-08-27 23:26:01 +02:00
Deploy
642272e155 fix(tpv-dev-in-prod): add agent field to gate JSONs for verify.sh compatibility 2026-08-27 23:20:30 +02:00
Deploy
7ed78f96eb fix(tpv-dev-in-prod): tPV production is running next dev (HMR + React DevTools visible) 2026-08-27 23:13:10 +02:00
Deploy
42b0290dd2 fix(pos-payment-overpay-fix): pOS selfpay: payments exceed total 2026-08-26 23:26:01 +02:00
Deploy
642fe037c2 fix(pos): clamp payment amount to total + allow 1c tolerance for rounding 2026-08-26 23:25:53 +02:00
Deploy
be1f014260 fix(pos): allow manifest + relax UUID validation on /pos/sales query 2026-08-26 23:20:30 +02:00
Deploy
592f937b91 fix(receipt-vat-breakdown): receipt VAT breakdown: discriminate types per line and subtotal 2026-08-26 22:58:39 +02:00
Deploy
3b11662229 feat(pos): receipt VAT breakdown — per-line rate + subtotals by type 2026-08-26 22:58:33 +02:00
Deploy
ff0d470845 fix(admin-pagination-global): global pagination component for all admin list pages 2026-08-26 22:56:49 +02:00
Deploy
74c6a69d14 fix(club): use real scannable QR code instead of pseudo-random grid 2026-08-26 22:55:53 +02:00
Deploy
06ac1918b2 feat(admin): global pagination component + apply to brands/promotions/reviews 2026-08-26 22:52:39 +02:00
Deploy
8eeac378da fix(orders-pagination): pagination in /admin/orders 2026-08-26 22:43:09 +02:00
Deploy
7f25d30fac fix(admin): add pagination to /admin/orders with prev/next and total counter 2026-08-26 22:43:01 +02:00
Deploy
b3e387c684 fix(order-status-completed): order status COMPLETED in admin dashboard and orders pages 2026-08-26 22:42:06 +02:00
Deploy
5f6a22f1c5 fix(admin): add COMPLETED state in dashboard, orders list and detail with lowercase label 2026-08-26 22:38:59 +02:00
Deploy
1ee3f0d1b2 fix(pos): fix JSX parse error in ReceiptModal error block 2026-08-26 22:33:54 +02:00
Deploy
92d151ee3d fix(pos): TPV never blocks on stock — physical scanner always wins 2026-08-26 22:32:13 +02:00
Deploy
7df6a003dc fix(pos): physical TPV (cashier) skips inventory check, selfpay still validates 2026-08-26 22:31:34 +02:00
Deploy
169e3e0c10 fix(pos-selfpay): sticky print button always visible in ReceiptModal 2026-08-26 22:28:10 +02:00
Deploy
88452df2cd fix(pos-selfpay): always show print button in ReceiptModal, hide email/return in selfpay 2026-08-26 22:26:17 +02:00
Deploy
15f36c3a7e fix(pos-selfpay): use confirmSaleWith to avoid stale state when confirming from modal 2026-08-26 22:24:14 +02:00
Deploy
c43a8527f1 fix(pos-selfpay): direct payment + confirm from modal, no partial payments 2026-08-26 22:22:56 +02:00
Deploy
e17a8d4de7 fix(pos-selfpay): payment methods shown as modal in selfpay mode only 2026-08-26 22:20:12 +02:00
Deploy
971411e1c2 fix(pos-selfpay): add Confirmar button after payment + print-only receipt 2026-08-26 22:11:08 +02:00
Deploy
eb655e109f fix(pos-selfpay): hide payment methods until email step completes 2026-08-26 22:03:23 +02:00
Deploy
665fc2a60d feat(club-006): club admin: dashboard, members, movements and settings 2026-08-26 20:55:00 +02:00
Deploy
db33706d2d feat(club-005): club registered users: account linking and automatic recovery 2026-08-26 20:49:36 +02:00
Deploy
3aecd2bec0 feat(club-004): club recovery: recovery codes and device reassignment 2026-08-26 20:47:06 +02:00
Deploy
3ccbf9f450 feat(pos-selfpay-flow): pOS selfpay: simplified pay flow with optional email receipt 2026-08-26 20:40:43 +02:00
Deploy
19f37b70e1 feat(pos-pwa): pOS: make TPV installable PWA and launch fullscreen 2026-08-26 20:38:19 +02:00
Deploy
158d55b8c6 fix(order-status-labels): admin order status labels should be lowercase and unified 2026-08-26 20:36:54 +02:00
Deploy
6795f25c27 fix(pdp-image-white-bg): product detail image card background must be white 2026-08-26 20:35:34 +02:00
Deploy
a73b1a9004 fix(checkout-stock-recheck): checkout insufficient stock error still appears after cart sync fix 2026-08-26 20:33:55 +02:00
Deploy
aaa802378b fix(checkout-returnto): checkout login: return user to checkout after sign in 2026-08-26 20:32:20 +02:00
Deploy
66290a08ae feat(club-003): club POS integration: identify member and register cashback from sales 2026-08-26 20:16:13 +02:00
Deploy
c263b44355 feat(club-002): club PWA: join flow, digital card and installable shell 2026-08-26 19:21:49 +02:00
Deploy
b2ccc70dc6 feat(club-001): club core backend: anonymous members, devices, ledger, cashback config 2026-08-26 18:55:00 +02:00
Deploy
c19626bea3 fix(inventory-opt): inventory optimization: pagination, queries, filters for 10k+ products 2026-08-26 18:07:49 +02:00
Deploy
49dfd00406 chore(checkpoint): save club core backend and pending pos fixes 2026-08-26 17:58:45 +02:00
Deploy
cf3c906ed2 fix(pos): redesign quick products admin search and save flow 2026-08-26 07:43:13 +02:00
Deploy
290ced173b fix(pos): handle expired API sessions and add receipt preview close button 2026-08-26 07:05:02 +02:00
Deploy
b6af852b54 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
2026-08-25 23:41:29 +02:00
Deploy
b6adf681d1 fix(pos): dedupe ticket header and force white print background 2026-08-25 22:39:05 +02:00
Deploy
129584d738 fix(pos): hide selfpay controls and normalize trend labels 2026-08-25 22:29:12 +02:00
Deploy
f76591518e fix(reporting): resize trend chart and repair closures detail 2026-08-25 22:24:11 +02:00
Deploy
ae42bae61e fix(admin): resolve notification and cash-close 500s 2026-08-25 22:16:08 +02:00
Deploy
ed031f8a94 feat(settings): add about system information 2026-08-25 22:08:20 +02:00
Deploy
3917c8d6de feat(orquestra): enforce semver and conventional commits 2026-08-25 22:03:50 +02:00
Deploy
66a08b61d2 fix: clean stale deployments before monolith start 2026-08-25 21:59:29 +02:00
chattie
9ae5f8e0de fix: use correct state column for AWAITING_PAYMENT orders
- Fix column o.payment_status does not exist error
- orders_orders uses 'state' column, not 'payment_status'
- Changed all 3 affected queries
2026-08-25 21:22:11 +02:00
chattie
0b0bba9be8 feat(TPV-FIXES): completed feature 2026-08-25 06:47:33 +02:00
chattie
99c9bd4c55 feat(ORDERS-FIX): completed feature 2026-08-25 06:38:10 +02:00
chattie
7639c1ff42 feat(TICKET-LOGO): completed feature 2026-08-25 06:35:39 +02:00
chattie
419f47ec1c feat(TPV-FIXES): completed feature 2026-08-25 06:31:00 +02:00
chattie
6dc4361f85 feat(FRONTEND-UI-FIXES2): completed feature 2026-08-24 23:05:13 +02:00
chattie
1ac7bcd20e feat(ADMIN-UI-FIXES2): completed feature 2026-08-24 23:05:13 +02:00
chattie
9daed56819 fix(admin): add twitterUrl/pinterestUrl to StoreSettings interface for settings page type safety 2026-08-24 22:17:01 +02:00
chattie
71165911ab fix(storefront+TPV): storefront live search proxy, product expiry/weight display, TPV receipt popup timestamp 2026-08-24 22:11:04 +02:00
chattie
2ed2ac8cd1 fix(admin): trend chart height 100px, logs 3-column layout, KPI cards clickable links, AI timeout 120s env var, X/Pinterest settings 2026-08-24 22:09:12 +02:00
294 changed files with 13413 additions and 976 deletions

View File

@@ -7780,6 +7780,538 @@
}, },
"phase": "storefront", "phase": "storefront",
"completed_at": "2026-08-24T14:13:16Z" "completed_at": "2026-08-24T14:13:16Z"
},
{
"id": "ADMIN-UI-FIXES2",
"type": "fix",
"title": "Admin UI fixes batch 2: trend chart, logs, KPI cards, AI timeout, X/Pinterest, product expiry/weight",
"description": "Need change",
"priority": "high",
"risk": "low",
"status": "done",
"created_at": "2026-08-24",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-24T21:05:13Z"
},
{
"id": "FRONTEND-UI-FIXES2",
"type": "fix",
"title": "Frontend/Storefront fixes 2: live search BASE URL, product expiry/weight, TPV receipt clock",
"description": "Need change",
"priority": "high",
"risk": "low",
"status": "done",
"created_at": "2026-08-24",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "frontend+storefront",
"completed_at": "2026-08-24T21:05:13Z"
},
{
"id": "INVENTORY-OPT",
"type": "fix",
"title": "Inventory optimization: pagination, queries, filters for 10k+ products",
"description": "Need change",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-24",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "backend",
"completed_at": "2026-08-26T16:07:48Z"
},
{
"id": "TPV-FIXES",
"type": "fix",
"title": "TPV fixes: Cashier label, favicon 404, pos/sales 400 error",
"description": "Need change",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-24",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "tpv",
"completed_at": "2026-08-25T04:47:32Z"
},
{
"id": "TICKET-LOGO",
"type": "feature",
"title": "TPV ticket header: allow custom text or logo upload",
"description": "Need change",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-24",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "tpv",
"completed_at": "2026-08-25T04:35:39Z"
},
{
"id": "ORDERS-FIX",
"type": "fix",
"title": "Orders detail: refund history shown in human-friendly format",
"description": "Need change",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-24",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-25T04:38:10Z"
},
{
"id": "SHIPPING-ZONES",
"type": "feature",
"title": "Shipping zones: restrict Balearic and Canary islands, add continental-only European zones",
"description": "Need change",
"priority": "med",
"risk": "low",
"status": "blocked",
"created_at": "2026-08-24",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "backend"
},
{
"id": "CHECKOUT-RETURNTO",
"type": "fix",
"title": "Checkout login: return user to checkout after sign in",
"description": "If checkout requires login and customer signs in, redirect back to the same checkout screen instead of home. Preserve intended route when auth starts from checkout.",
"priority": "high",
"risk": "low",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "frontend",
"completed_at": "2026-08-26T18:32:20Z"
},
{
"id": "CHECKOUT-STOCK-RECHECK",
"type": "bug",
"title": "Checkout insufficient stock error still appears after cart sync fix",
"description": "Investigate why frontend still shows raw INSUFFICIENT_STOCK JSON / requested 17 vs available 16 after the recent fix. Verify deployment/version and fix any remaining stale cart sync or proxy behavior.",
"priority": "high",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "frontend",
"completed_at": "2026-08-26T18:33:55Z"
},
{
"id": "PDP-IMAGE-WHITE-BG",
"type": "fix",
"title": "Product detail image card background must be white",
"description": "On product detail pages such as /products/proteina-guisante-ecologica, the image card background is light gray and should be white.",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "frontend",
"completed_at": "2026-08-26T18:35:34Z"
},
{
"id": "ORDER-STATUS-LABELS",
"type": "fix",
"title": "Admin order status labels should be lowercase and unified",
"description": "Dashboard 'Pedidos por estado' shows COMPLETED in uppercase. Display lowercase/humanized labels and use the same final-order label in the admin orders screens.",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-26T18:36:54Z"
},
{
"id": "POS-PWA",
"type": "feature",
"title": "POS: make TPV installable PWA and launch fullscreen",
"description": "Convert the POS app into an installable PWA with manifest/icons and fullscreen display mode so TPV terminals open like a kiosk app.",
"priority": "med",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "pos",
"completed_at": "2026-08-26T18:38:19Z"
},
{
"id": "POS-SELFPAY-FLOW",
"type": "feature",
"title": "POS selfpay: simplified pay flow with optional email receipt",
"description": "In selfpay mode show only 'Pagar' and 'Limpiar ticket'. On pay, ask optional email, then show payment methods. Cash prints the ticket and sends customer to cashier; if email was provided, also send the receipt by email. Keep card/other methods ready for future posnet integration.",
"priority": "high",
"risk": "high",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "pos",
"completed_at": "2026-08-26T18:40:43Z"
},
{
"id": "CLUB-001",
"type": "feature",
"title": "Club core backend: anonymous members, devices, ledger, cashback config",
"description": "Phase 1. Add club members, device tokens, recovery-ready identity model, transaction ledger as source of truth, cashback config, basic backend endpoints, migrations and tests.",
"priority": "high",
"risk": "high",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "backend",
"completed_at": "2026-08-26T16:54:59Z"
},
{
"id": "CLUB-002",
"type": "feature",
"title": "Club PWA: join flow, digital card and installable shell",
"description": "Phase 2. Add /club/join, /club/card, QR card UI, manifest, standalone installability and mobile-first Club PWA shell.",
"priority": "high",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "frontend",
"completed_at": "2026-08-26T17:21:49Z"
},
{
"id": "CLUB-003",
"type": "feature",
"title": "Club POS integration: identify member and register cashback from sales",
"description": "Phase 3. Let TPV scan Club QR, identify members, preview balance/use, record sale-linked cashback and refund-safe idempotent club transactions.",
"priority": "high",
"risk": "high",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "pos",
"completed_at": "2026-08-26T18:16:13Z"
},
{
"id": "CLUB-004",
"type": "feature",
"title": "Club recovery: recovery codes and device reassignment",
"description": "Phase 4. Add secure recovery codes, /club/recover and new-device relinking for anonymous members without accounts.",
"priority": "med",
"risk": "high",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "backend",
"completed_at": "2026-08-26T18:47:06Z"
},
{
"id": "CLUB-005",
"type": "feature",
"title": "Club registered users: account linking and automatic recovery",
"description": "Phase 5. Link club members to Mercado de Vida users, restore card automatically after login and handle safe merge between anonymous and registered memberships.",
"priority": "med",
"risk": "high",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "backend",
"completed_at": "2026-08-26T18:49:36Z"
},
{
"id": "CLUB-006",
"type": "feature",
"title": "Club admin: dashboard, members, movements and settings",
"description": "Phase 6. Add admin Club de Clientes section with dashboard, member list, movements and configuration screens.",
"priority": "med",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-26T18:55:00Z"
},
{
"id": "ORDER-STATUS-COMPLETED",
"type": "fix",
"title": "Order status COMPLETED in admin dashboard and orders pages",
"description": "POS sales use COMPLETED order state. Show COMPLETED in admin dashboard ordersByState, orders list, and order detail pages with proper Spanish label and color. Include COMPLETED in revenue calculations.",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-26T20:42:05Z"
},
{
"id": "ORDERS-PAGINATION",
"type": "fix",
"title": "Pagination in /admin/orders",
"description": "Admin orders page shows only 20 orders with no pagination. Add offset/limit controls, prev/next buttons, and total counter.",
"priority": "med",
"risk": "low",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-26T20:43:08Z"
},
{
"id": "ADMIN-PAGINATION-GLOBAL",
"type": "fix",
"title": "Global pagination component for all admin list pages",
"description": "Add a reusable Pagination component to all admin list/CRUD pages: customers, products, payments, reviews, users, brands, categories, promotions, shipping, inventory, club. Apply consistent pattern (offset+limit+total).",
"priority": "med",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-26T20:56:49Z"
},
{
"id": "RECEIPT-VAT-BREAKDOWN",
"type": "fix",
"title": "Receipt VAT breakdown: discriminate types per line and subtotal",
"description": "POS receipt should show VAT type (general/reduced/super-reduced) per line item AND break down subtotals by VAT type. Currently only shows total IVA without discrimination.",
"priority": "med",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "pos",
"completed_at": "2026-08-26T20:58:39Z"
},
{
"id": "POS-PAYMENT-OVERPAY-FIX",
"type": "bug",
"title": "POS selfpay: payments exceed total",
"description": "In selfpay flow, after selecting payment method the frontend reports payments exceed total. Investigate discrepancy between frontend totals.total and backend totalCents.",
"priority": "high",
"risk": "med",
"status": "done",
"created_at": "2026-08-26",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "pos",
"completed_at": "2026-08-26T21:26:01Z"
},
{
"id": "TPV-POS-SALES-500",
"type": "bug",
"title": "TPV /api/pos/sales returns HTTP 500",
"description": "POST /api/pos/sales responds with HTTP 500 in production. Needs backend traces to reproduce and identify root cause (likely validation, DB connection or idempotency regression). Currently blocking real sales on the TPV.",
"priority": "high",
"risk": "med",
"status": "pending",
"created_at": "2026-08-27",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "pos"
},
{
"id": "TPV-DEV-IN-PROD",
"type": "bug",
"title": "TPV production is running next dev (HMR + React DevTools visible)",
"description": "Deployed TPV at tpv-mv.rikrdo.com is running next dev instead of next start. Console shows HMR WebSocket connections, [HMR] connected messages, and React DevTools download notice. Only next dev emits HMR; in production must be next start.",
"priority": "high",
"risk": "med",
"status": "done",
"created_at": "2026-08-27",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "pos",
"completed_at": "2026-08-27T21:13:10Z"
},
{
"id": "TPV-MANIFEST-PATH",
"type": "bug",
"title": "TPV manifest path mismatch: layout points /manifest.json but Next publishes /manifest.webmanifest",
"description": "project/apps/pos/src/app/layout.tsx declares metadata.manifest: '/manifest.json' but the App Router file project/apps/pos/src/app/manifest.ts is served at /manifest.webmanifest. Browser auto-requests /manifest.webmanifest -> 404/redirect to Authelia -> CORS error. Fix: point metadata.manifest to /manifest.webmanifest (or set fileName) AND add the path to Authelia bypass list.",
"priority": "high",
"risk": "low",
"status": "pending",
"created_at": "2026-08-27",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "pos"
},
{
"id": "TPV-FAVICON-404",
"type": "chore",
"title": "TPV missing favicon.ico (404 in browser console)",
"description": "No favicon.ico served at the TPV origin. Browser console reports 404 on /favicon.ico. Bundle with TPV-MANIFEST-PATH or add a static favicon.",
"priority": "low",
"risk": "low",
"status": "pending",
"created_at": "2026-08-27",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "pos"
},
{
"id": "NOTIF-INVENTORY-ALERTS",
"type": "feature",
"title": "Admin bell: add inventory alerts (low stock, out of stock, expired/expiring, low margin)",
"description": "Extend admin header bell notification (currently only shows stale orders + awaiting payment) with inventory operational alerts: (1) low stock (configurable threshold, default <5), (2) out of stock, (3) expired and expiring soon (configurable days, default 30), (4) low margin (configurable %, default <30%). Backend: extend /api/backoffice/notifications to include inventory counts reusing the queries from inventory.routes.ts. Frontend: add Inventario tab to OrderNotifications dropdown with iconified items, each linking to /inventory?filter=low_stock|out_of_stock|expiring|low_margin. Counts must match /api/inventory totals. Thresholds read from store_settings (admin configurable). Acceptance: bell badge updates; each alert navigates to filtered inventory; thresholds configurable via admin/settings.",
"priority": "high",
"risk": "med",
"status": "done",
"created_at": "2026-08-27",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-27T21:26:01Z"
},
{
"id": "DASH-REMOVE-VARIANTS-CARD",
"type": "chore",
"title": "Dashboard: remove variants KPI cards (Sin stock + Alertas)",
"description": "Two dashboard KPI cards reference product variants: (1) 'Sin stock' in primary row with sub 'Variantes agotadas', (2) 'Alertas' in secondary row with sub 'X sin stock' / 'Revisar inventario'. Since we no longer work with product variants (F-108 removed variants module from product editor), these cards are misleading. Additionally, the 'Alertas' card is redundant with the OrderNotifications bell (NOTIF-INVENTORY-ALERTS closed) which now shows all inventory alerts. Remove both cards. Keep backend /api/admin/stats returning outOfStockVariants unchanged (still useful for API consumers).",
"priority": "low",
"risk": "low",
"status": "done",
"created_at": "2026-08-27",
"gates": {
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"phase": "admin",
"completed_at": "2026-08-27T21:28:43Z"
} }
] ]
} }

View File

@@ -138,7 +138,7 @@ cd project
./scripts/monolith.sh prod start ./scripts/monolith.sh prod start
``` ```
Usa `npm ci`, aplica migraciones, construye backend/admin/frontend/storefront y arranca todo en `0.0.0.0`. Usa `npm ci`, aplica migraciones, construye backend/admin/frontend/storefront y arranca todo en `0.0.0.0`. Al finalizar ejecuta un **smoke test post-arranque** que verifica que ninguno de los servicios Next está sirviendo modo dev (sin HMR, sin `react-refresh`, sin banner `(Turbopack)`). Si detecta un dev server activo, aborta el arranque con un mensaje claro.
### Redeploy / reinicio completo ### Redeploy / reinicio completo
@@ -149,6 +149,51 @@ cd project
Detiene únicamente PIDs registrados en `.runtime/prod/`; nunca usa `pkill` global. Si un puerto está ocupado por un proceso ajeno, falla y muestra el PID. Detiene únicamente PIDs registrados en `.runtime/prod/`; nunca usa `pkill` global. Si un puerto está ocupado por un proceso ajeno, falla y muestra el PID.
### 3.1 Errores comunes — jamás arranques `dev` en un host de producción
**Regla de oro:** en cualquier host expuesto al público (sea LAN, VPS o servidor de tienda), usa **siempre** `./scripts/monolith.sh prod`. El modo `dev` está reservado para desarrollo local en `127.0.0.1`.
#### Por qué `next dev` no es un atajo válido en producción
- **Expone internals:** el bundle del cliente incluye `react-refresh`, `__webpack_require__`, el cliente HMR y los mensajes "Download the React DevTools". Cualquier visitante ve el código fuente sin minificar.
- **Hackeable:** el endpoint `/_next/hmr` queda abierto y acepta WebSocket upgrades. Esto filtra nombres de archivos del servidor, permite lecturas no autenticadas del árbol de fuentes y rompe asunciones de seguridad (CSP, cookies Secure, CORS).
- **Rota el PWA:** Chrome exige un `manifest.webmanifest` válido. `next dev` no genera el manifest correctamente en algunos setups y dispara errores CORS cuando un auth-proxy (Authelia) intenta proteger la ruta.
- **Rendimiento ~10× peor:** Turbopack compila cada request bajo demanda y mantiene cachés en memoria que pueden llegar a GB. Sin monitorización, esto degrada el TPV hasta hacerlo inutilizable en horas punta.
- **Rompe el ciclo de release:** los artefactos `.next/` de dev no son válidos para `next start`. Si mezclas `dev` y `start` en el mismo árbol, los reinicios de prod fallan silenciosamente porque los PIDs no se registran.
#### Señales de que estás corriendo `dev` por accidente
- El log del servicio dice `▲ Next.js 16.3.1 (Turbopack)` — la coletilla `(Turbopack)` solo aparece en `next dev`.
- La consola del navegador muestra intentos de WebSocket a `/_next/hmr` y mensajes `[HMR] connected` repetidos.
- Verás `Download the React DevTools` al cargar cualquier página.
- `lsof -nP -iTCP:3002 -sTCP:LISTEN` muestra un PID de `node .../next/dist/bin/next dev` en lugar de `next start`.
#### Runbook de recuperación rápida (si ya estás sirviendo dev en prod)
```bash
# 1) Identificar el proceso dev en :3002 (TPV) y :3001 (admin)
lsof -nP -iTCP:3002 -sTCP:LISTEN
lsof -nP -iTCP:3001 -sTCP:LISTEN
# 2) Detenerlo (sustituye <PID> por el del paso anterior)
kill -TERM <PID>
# 3) Confirmar que el puerto queda libre
lsof -nP -iTCP:3002 -sTCP:LISTEN # debe devolver nada
# 4) Limpiar runtime stale (incluye dev huérfano)
rm -f project/.runtime/dev/*.pid
# 5) Arrancar prod (con smoke test post-arranque)
cd project && ./scripts/monolith.sh prod start
# 6) Confirmar: el log dirá "▲ Next.js 16.3.1" SIN "(Turbopack)"
tail project/.runtime/prod/tpv.log
# debe terminar con la línea de Ready y NADA de WebSocket /_next/hmr
```
Si el smoke test falla igualmente, abre un ticket: hay otro proceso (posiblemente externo al script) ocupando el puerto y `monolith.sh` no puede broad-kill por seguridad.
### Estado, logs y stop ### Estado, logs y stop
```bash ```bash
@@ -279,6 +324,22 @@ curl -fsS -o /dev/null http://127.0.0.1:3004/
curl -fsS -o /dev/null http://127.0.0.1:3005/ curl -fsS -o /dev/null http://127.0.0.1:3005/
``` ```
Comprobaciones adicionales anti-dev (TPV-DEV-IN-PROD):
```bash
# 1) El log del TPV NO debe contener "(Turbopack)"
! grep -F '(Turbopack)' project/.runtime/prod/tpv.log
# 2) /_next/hmr debe devolver 404 o 426 (no 200/101/405)
code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:3002/_next/hmr)
[[ "$code" == "404" || "$code" == "426" ]]
# 3) El HTML inicial NO debe contener marcadores de dev
! curl -fsS http://127.0.0.1:3002/ | grep -qE '/__next_hmr|react-refresh|Download the React DevTools'
```
Repetir para `:3001` (admin), `:3003` (frontend) y `:3004` (storefront) si también están en producción. Si cualquiera falla, sigue el runbook de §3.1 antes de continuar.
--- ---
## 7. Problemas frecuentes ## 7. Problemas frecuentes

55
docs/deployment.md Normal file
View File

@@ -0,0 +1,55 @@
# Deploy del monolito MercadoDeVida
## ¿Basta con copiar `project/`?
Sí, para levantar el monolito en un host basta con traer la carpeta `project/` completa y ejecutar:
```bash
cd project
./scripts/monolith.sh prod start
```
o, para reemplazar una versión anterior ya corriendo:
```bash
cd project
./scripts/monolith.sh prod restart
```
## Qué hace `monolith.sh prod start/restart`
1. Limpia procesos antiguos del proyecto y listeners en puertos gestionados.
2. Arranca PostgreSQL y Redis con Docker Compose.
3. Instala dependencias con `npm ci` en backend, admin, TPV, frontend y storefront.
4. Ejecuta migraciones.
5. Compila backend y frontends.
6. Arranca servicios con `nohup` y guarda PID/logs en `project/.runtime/prod/`.
7. Verifica HTTP para cada servicio.
Servicios por defecto:
| Servicio | Puerto |
|---|---:|
| Backend API | 3000 |
| Admin | 3001 |
| TPV | 3002 |
| Frontend | 3003 |
| Storefront SEO | 3004 |
## Requisitos del host
- Node.js >= 22
- npm
- Docker + Docker Compose
- Bash, curl, lsof
- Variables/secretos en `project/.env`
Si no existe `.env`, el script crea uno de desarrollo. En producción real conviene crear `.env` explícito antes de arrancar.
## Lo que NO gestiona
- Traefik/Nginx/Authelia o certificados TLS.
- Arranque automático tras reinicio del host.
- Backups de base de datos.
Para producción estable conviene envolver `./scripts/monolith.sh prod restart` con systemd, supervisor o el orquestador elegido.

View File

@@ -65,6 +65,8 @@ npm run build
The monolith script handles each app independently; a single `restart` rebuilds and respawns all services. The monolith script handles each app independently; a single `restart` rebuilds and respawns all services.
> ⚠️ **Nunca** ejecutes `npm run dev` ni `./scripts/monolith.sh dev` en el host público del TPV. El modo dev expone HMR, React DevTools y endpoints internos a cualquier visitante, y rompe la PWA (manifest y cookies Secure). Para producción usa exclusivamente `prod`; tras el arranque, `monolith.sh` ejecuta un smoke test que falla ruidosamente si detecta un dev server activo. Detalles y runbook de recuperación en `docs/HOWTO-monolith.md` §3.1.
--- ---
## 2. Daily operations ## 2. Daily operations

83
docs/versioning.md Normal file
View File

@@ -0,0 +1,83 @@
# Versionado semántico y Conventional Commits
## Fuente de verdad
La versión del producto vive en:
```txt
project/VERSION
```
Los `package.json` del monolito deben mantenerse sincronizados con ese valor.
## Conventional Commits obligatorios
Formato:
```txt
<type>[optional scope][!]: <description>
```
Tipos permitidos:
- `feat`
- `fix`
- `docs`
- `chore`
- `refactor`
- `perf`
- `test`
- `build`
- `ci`
- `style`
- `revert`
Ejemplos:
```txt
feat(orders): add awaiting-payment notifications
fix(monolith): clean stale deployments before start
chore(release): v0.2.0
```
## Herramientas Orquestra
Validar un mensaje:
```bash
python3 scripts/validate_conventional_commit.py --message "fix(monolith): clean stale deployments"
```
Instalar hook local de Git:
```bash
./scripts/install_git_hooks.sh
```
Bump semver:
```bash
python3 scripts/version.py bump patch
python3 scripts/version.py bump minor --commit --tag
python3 scripts/version.py bump major --commit --tag
```
Ver versión actual:
```bash
python3 scripts/version.py show
```
## Cierre de features
`scripts/commit_feature.sh` genera commits convencionales automáticamente usando el tipo del ticket:
- `feature``feat(...)`
- `fix` / `bug``fix(...)`
- `chore``chore(...)`
`verify.sh` valida:
- `project/VERSION` como semver.
- `package.json` sincronizados.
- último commit compatible con Conventional Commits.

1
project/VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.10

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { Brand } from '@/types'; import type { Brand } from '@/types';
import { brandsApi } from '@/lib/api-client'; import { brandsApi } from '@/lib/api-client';
import { RowActions } from '@/components/ui/RowActions'; import { RowActions } from '@/components/ui/RowActions';
import Pagination from '@/components/ui/Pagination';
function slugify(text: string): string { function slugify(text: string): string {
return text return text
@@ -22,7 +23,10 @@ function autoSeoDescription(name: string): string {
} }
export default function BrandsPage() { export default function BrandsPage() {
const PAGE_SIZE = 20;
const [brands, setBrands] = useState<Brand[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -42,14 +46,15 @@ export default function BrandsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const data = await brandsApi.list(); const data = await brandsApi.list({ limit: PAGE_SIZE, offset });
setBrands((data as { items?: Brand[] }).items ?? []); setBrands(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Error'); setError(e instanceof Error ? e.message : 'Error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [offset]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -210,9 +215,14 @@ export default function BrandsPage() {
)} )}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> : {loading ? (
error ? <div className="text-center text-red-600">{error}</div> : <div className="p-12 text-center text-gray-400">Cargando...</div>
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> : ) : error ? (
<div className="text-center text-red-600 p-12">{error}</div>
) : brands.length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay marcas</div>
) : (
<>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-gray-50 border-b border-gray-200"> <tr className="bg-gray-50 border-b border-gray-200">
@@ -235,7 +245,9 @@ export default function BrandsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
} <Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,175 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { clubAdminApi } from '@/lib/api-client';
const TX_LABELS: Record<string, string> = {
earn: 'Cashback',
redeem: 'Canje',
refund: 'Reembolso',
bonus: 'Bono',
adjustment: 'Ajuste',
};
const TX_COLORS: Record<string, string> = {
earn: 'text-green-700',
redeem: 'text-blue-700',
refund: 'text-orange-700',
bonus: 'text-purple-700',
adjustment: 'text-gray-700',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
interface MemberDetail {
id: string;
userId: string | null;
userEmail: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
}
interface Transaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: string;
amountCents: number;
balanceDeltaCents: number;
createdAt: string;
}
export default function ClubMemberPage() {
const { id } = useParams<{ id: string }>();
const [member, setMember] = useState<MemberDetail | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await clubAdminApi.getMemberMovements(id);
setMember(data.member as MemberDetail);
setTransactions(data.items as Transaction[]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { void load(); }, [load]);
if (loading) {
return (
<div className="p-8 text-center text-gray-400">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
);
}
if (error || !member) {
return (
<div className="p-8 text-center">
<p className="text-red-600 text-sm mb-3">{error || 'Socio no encontrado'}</p>
<Link href="/club" className="text-sm text-[#2D6A4F] hover:underline"> Volver al Club</Link>
</div>
);
}
return (
<div className="p-8">
<div className="mb-6">
<Link href="/club" className="text-sm text-[#2D6A4F] hover:underline mb-4 inline-block"> Club de Clientes</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{member.memberCode}</h1>
<p className="text-sm text-gray-500 mt-1">
{member.userEmail ?? 'Socio anónimo'}
{!member.userId && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded">Sin cuenta</span>}
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-gray-900">{formatPrice(member.currentBalanceCents)}</p>
<p className="text-xs text-gray-500">Saldo actual</p>
<span className={`inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium ${
member.status === 'active' ? 'bg-green-100 text-green-800' :
member.status === 'blocked' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-600'
}`}>
{member.status}
</span>
</div>
</div>
</div>
{/* Meta */}
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Datos del socio</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div><span className="text-gray-500">ID:</span> <span className="font-mono text-gray-700">{member.id}</span></div>
<div><span className="text-gray-500">Código:</span> <span className="font-mono text-gray-700">{member.memberCode}</span></div>
<div><span className="text-gray-500">Email:</span> <span className="text-gray-700">{member.userEmail ?? '—'}</span></div>
<div><span className="text-gray-500">User ID:</span> <span className="font-mono text-gray-700">{member.userId ?? '—'}</span></div>
<div><span className="text-gray-500">Alta:</span> <span className="text-gray-700">{formatDate(member.createdAt)}</span></div>
<div><span className="text-gray-500">Última actualización:</span> <span className="text-gray-700">{formatDate(member.updatedAt)}</span></div>
</div>
</div>
{/* Movements */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Movimientos</h2>
</div>
{transactions.length === 0 ? (
<div className="p-12 text-center text-gray-400 text-sm">Sin movimientos</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Fecha', 'Tipo', 'Importe', 'Delta'].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-5 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{transactions.map((tx) => (
<tr key={tx.id} className="hover:bg-gray-50">
<td className="px-5 py-3 text-sm text-gray-600">{formatDate(tx.createdAt)}</td>
<td className="px-5 py-3">
<span className={`text-sm font-medium ${TX_COLORS[tx.type] ?? 'text-gray-700'}`}>
{TX_LABELS[tx.type] ?? tx.type}
</span>
</td>
<td className="px-5 py-3 text-sm text-gray-700">{formatPrice(tx.amountCents)}</td>
<td className={`px-5 py-3 text-sm font-bold ${tx.balanceDeltaCents >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{tx.balanceDeltaCents >= 0 ? '+' : ''}{formatPrice(tx.balanceDeltaCents)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { clubAdminApi } from '@/lib/api-client';
type MemberStatus = 'active' | 'blocked' | 'merged';
const STATUS_LABELS: Record<MemberStatus, string> = {
active: 'Activo',
blocked: 'Bloqueado',
merged: 'Fusionado',
};
const STATUS_COLORS: Record<MemberStatus, string> = {
active: 'bg-green-100 text-green-800',
blocked: 'bg-red-100 text-red-800',
merged: 'bg-gray-100 text-gray-600',
};
function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
function timeAgo(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Hoy';
if (diffDays === 1) return 'Ayer';
if (diffDays < 30) return `Hace ${diffDays} días`;
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
}
export default function ClubPage() {
const [members, setMembers] = useState<Array<{
id: string; userId: string | null; userEmail: string | null;
memberCode: string; status: MemberStatus; tierCode: string;
currentBalanceCents: number; createdAt: string;
}>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const limit = 20;
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await clubAdminApi.listMembers({
q: debouncedSearch || undefined,
status: filterStatus || undefined,
limit,
offset,
});
setMembers(data.items as typeof members);
setTotal(data.total);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
setLoading(false);
}
}, [debouncedSearch, filterStatus, offset]);
useEffect(() => { void load(); }, [load]);
const totalPages = Math.ceil(total / limit);
return (
<div className="p-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Club de Clientes</h1>
<p className="text-sm text-gray-500 mt-0.5">{total} socios</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-3 mb-6 flex-wrap">
<div className="relative flex-1 max-w-xs">
<input
type="search"
placeholder="Buscar por código o email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setOffset(0); }}
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
>
<option value="">Todos los estados</option>
<option value="active">Activo</option>
<option value="blocked">Bloqueado</option>
<option value="merged">Fusionado</option>
</select>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
<p className="mt-2 text-sm">Cargando...</p>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={() => void load()} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar
</button>
</div>
) : members.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">🎫</p>
<p className="text-gray-500 text-sm">No hay socios del Club</p>
</div>
) : (
<>
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['Código', 'Email', 'Estado', 'Saldo', 'Alta'].map((h) => (
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{members.map((m) => (
<tr key={m.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<Link href={`/club/${m.id}`} className="text-sm font-mono text-[#2D6A4F] hover:underline">
{m.memberCode}
</Link>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{m.userEmail ?? '—'}</p>
{m.userEmail && <p className="text-xs text-gray-400">Anónimo vinculado</p>}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[m.status]}`}>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
{STATUS_LABELS[m.status]}
</span>
</td>
<td className="px-4 py-3">
<p className="text-sm font-bold text-gray-900">{formatPrice(m.currentBalanceCents)}</p>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{timeAgo(m.createdAt)}</p>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
{offset + 1}{Math.min(offset + limit, total)} de {total}
</p>
<div className="flex gap-1">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-3 py-1 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
Anterior
</button>
<button
onClick={() => setOffset(offset + limit)}
disabled={offset + limit >= total}
className="px-3 py-1 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-gray-50"
>
Siguiente
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,26 +1,16 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react';
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
import type { ProductVariant, StockAvailability, VariantPrice } from '@/types';
interface ProductRow { import { useCallback, useEffect, useState } from 'react';
productId: string; import { inventoryApi, productsApi } from '@/lib/api-client';
productName: string; import type { InventoryOverviewItem } from '@/types';
productSlug: string;
productActive: boolean; interface ProductRow extends InventoryOverviewItem {
variant: ProductVariant;
stock: StockAvailability | null;
price: VariantPrice | null;
expirationDate: string | null;
loading: boolean;
editing: boolean; editing: boolean;
editValue: string; editValue: string;
saving: boolean; saving: boolean;
msg: string;
editingEan: boolean; editingEan: boolean;
editEanValue: string; editEanValue: string;
savingEan: boolean; savingEan: boolean;
// F-130: caducidad editable inline
editingExp: boolean; editingExp: boolean;
editExpValue: string; editExpValue: string;
savingExp: boolean; savingExp: boolean;
@@ -37,19 +27,30 @@ const FILTER_LABELS: Record<RowFilter, string> = {
low_margin: '📉 Margen <30%', low_margin: '📉 Margen <30%',
}; };
const VAT_MULTIPLIER: Record<string, number> = { const PAGE_SIZE = 50;
general: 1.21,
reduced: 1.1,
'super-reduced': 1.04,
};
function StockBadge({ qty }: { qty: number }) { function StockBadge({ qty }: { qty: number }) {
if (qty === 0) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Sin stock</span>; if (qty === 0) {
if (qty < 5) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">Stock bajo ({qty})</span>; return (
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({qty})</span>; <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
Sin stock
</span>
);
}
if (qty < 5) {
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
Stock bajo ({qty})
</span>
);
}
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
En stock ({qty})
</span>
);
} }
/** Días hasta la fecha de caducidad (negativo si ya caducó). */
function daysUntil(dateIso: string): number { function daysUntil(dateIso: string): number {
const target = new Date(`${dateIso.slice(0, 10)}T00:00:00`); const target = new Date(`${dateIso.slice(0, 10)}T00:00:00`);
const today = new Date(); const today = new Date();
@@ -57,26 +58,27 @@ function daysUntil(dateIso: string): number {
return Math.round((target.getTime() - today.getTime()) / 86_400_000); return Math.round((target.getTime() - today.getTime()) / 86_400_000);
} }
/** Margen bruto % = (PVP coste) / PVP × 100. Null si falta coste o precio. */
function marginPercent(price: VariantPrice | null): number | null {
if (!price || price.costCents === null) return null;
const grossCents = Math.round(price.netUnitAmountCents * (VAT_MULTIPLIER[price.vatRate] ?? 1.21));
if (grossCents <= 0) return null;
return Math.round(((grossCents - price.costCents) / grossCents) * 100);
}
function ExpirationCell({ dateIso }: { dateIso: string | null }) { function ExpirationCell({ dateIso }: { dateIso: string | null }) {
if (!dateIso) return <span className="text-gray-300"></span>; if (!dateIso) return <span className="text-gray-300"></span>;
const days = daysUntil(dateIso); const days = daysUntil(dateIso);
const label = new Date(`${dateIso.slice(0, 10)}T00:00:00`).toLocaleDateString('es-ES', { const label = new Date(`${dateIso.slice(0, 10)}T00:00:00`).toLocaleDateString('es-ES', {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit',
month: '2-digit',
year: 'numeric',
}); });
if (days < 0) { if (days < 0) {
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Caducado ({label})</span>; return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
Caducado ({label})
</span>
);
} }
if (days <= 30) { if (days <= 30) {
return ( return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700" title={label}> <span
className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700"
title={label}
>
Caduca en {days} día{days === 1 ? '' : 's'} Caduca en {days} día{days === 1 ? '' : 's'}
</span> </span>
); );
@@ -84,6 +86,21 @@ function ExpirationCell({ dateIso }: { dateIso: string | null }) {
return <span className="text-xs text-gray-600">{label}</span>; return <span className="text-xs text-gray-600">{label}</span>;
} }
function toRow(item: InventoryOverviewItem): ProductRow {
return {
...item,
editing: false,
editValue: String(item.stock.availableQuantity),
saving: false,
editingEan: false,
editEanValue: item.variant.ean ?? '',
savingEan: false,
editingExp: false,
editExpValue: item.expirationDate?.slice(0, 10) ?? '',
savingExp: false,
};
}
export default function InventoryPage() { export default function InventoryPage() {
const [rows, setRows] = useState<ProductRow[]>([]); const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -91,103 +108,65 @@ export default function InventoryPage() {
const [filter, setFilter] = useState<RowFilter>('all'); const [filter, setFilter] = useState<RowFilter>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
const [stats, setStats] = useState({ total: 0, inStock: 0, lowStock: 0, outOfStock: 0 });
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400); const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [search]); }, [search]);
// Load products + single internal variant + stock + price (F-108/F-110) useEffect(() => {
setOffset(0);
}, [debouncedSearch, filter]);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const { items: products } = await productsApi.list({ const result = await inventoryApi.listOverview({
limit: 100,
q: debouncedSearch || undefined, q: debouncedSearch || undefined,
filter,
limit: PAGE_SIZE,
offset,
}); });
setRows(result.items.map(toRow));
const productRows: ProductRow[] = []; setTotal(result.total);
setStats(result.stats);
for (const product of products ?? []) {
const { items: variants } = await productsApi.getVariants(product.id);
const variant = variants?.[0];
if (!variant) continue;
productRows.push({
productId: product.id,
productName: product.name,
productSlug: product.slug,
productActive: product.state === 'active',
variant,
stock: null,
price: null,
expirationDate: product.expirationDate ?? null,
loading: true,
editing: false,
editValue: '',
saving: false,
msg: '',
editingEan: false,
editEanValue: '',
savingEan: false,
editingExp: false,
editExpValue: product.expirationDate?.slice(0, 10) ?? '',
savingExp: false,
});
}
setRows(productRows);
for (const row of productRows) {
inventoryApi.getAvailability(row.variant.id)
.then((stock) => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
: r,
),
);
})
.catch(() => {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, loading: false, editValue: '0' } : r,
),
);
});
pricingApi.getVariantPrice(row.variant.id)
.then((price) => {
setRows((prev) =>
prev.map((r) => (r.variant.id === row.variant.id ? { ...r, price } : r)),
);
})
.catch(() => {
// Sin precio: margen no disponible.
});
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Error al cargar inventario'); setError(e instanceof Error ? e.message : 'Error al cargar inventario');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [debouncedSearch]); }, [debouncedSearch, filter, offset]);
useEffect(() => { load(); }, [load]); useEffect(() => {
void load();
}, [load]);
// Save EAN inline
const handleSaveEan = async (variantId: string, productId: string, newEan: string) => { const handleSaveEan = async (variantId: string, productId: string, newEan: string) => {
setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, savingEan: true } : r)); setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, savingEan: true } : r)));
try { try {
const updated = await productsApi.updateVariant(productId, variantId, { ean: newEan || null }); const updated = await productsApi.updateVariant(productId, variantId, { ean: newEan || null });
setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, variant: { ...r.variant, ean: updated.ean }, editingEan: false, savingEan: false } : r)); setRows((prev) =>
prev.map((r) =>
r.variant.id === variantId
? {
...r,
variant: { ...r.variant, ean: updated.ean },
editingEan: false,
editEanValue: updated.ean ?? '',
savingEan: false,
}
: r,
),
);
} catch { } catch {
setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, savingEan: false } : r)); setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, savingEan: false } : r)));
} }
}; };
// Save expiration date inline (F-130)
const handleSaveExp = async (variantId: string, productId: string, value: string) => { const handleSaveExp = async (variantId: string, productId: string, value: string) => {
setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, savingExp: true } : r))); setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, savingExp: true } : r)));
try { try {
@@ -195,7 +174,7 @@ export default function InventoryPage() {
const newIso = updated.expirationDate ?? null; const newIso = updated.expirationDate ?? null;
setRows((prev) => setRows((prev) =>
prev.map((r) => prev.map((r) =>
r.variant.id === variantId r.productId === productId
? { ? {
...r, ...r,
expirationDate: newIso, expirationDate: newIso,
@@ -211,14 +190,13 @@ export default function InventoryPage() {
} }
}; };
// Save Stock inline (used by Stock cell on blur/Enter)
const saveStockInline = async (variantId: string, value: string) => { const saveStockInline = async (variantId: string, value: string) => {
const qty = parseInt(value, 10); const qty = parseInt(value, 10);
if (isNaN(qty) || qty < 0) { if (isNaN(qty) || qty < 0) {
setRows((prev) => setRows((prev) =>
prev.map((r) => prev.map((r) =>
r.variant.id === variantId r.variant.id === variantId
? { ...r, editing: false, editValue: String(r.stock?.availableQuantity ?? 0), msg: 'Error' } ? { ...r, editing: false, editValue: String(r.stock.availableQuantity) }
: r, : r,
), ),
); );
@@ -236,14 +214,11 @@ export default function InventoryPage() {
editing: false, editing: false,
saving: false, saving: false,
editValue: String(result.available), editValue: String(result.available),
msg: '✓ Guardado',
} }
: r, : r,
), ),
); );
setTimeout(() => { await load();
setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, msg: '' } : r)));
}, 3000);
} catch { } catch {
setRows((prev) => setRows((prev) =>
prev.map((r) => prev.map((r) =>
@@ -252,8 +227,7 @@ export default function InventoryPage() {
...r, ...r,
saving: false, saving: false,
editing: false, editing: false,
editValue: String(r.stock?.availableQuantity ?? 0), editValue: String(r.stock.availableQuantity),
msg: 'Error',
} }
: r, : r,
), ),
@@ -261,57 +235,37 @@ export default function InventoryPage() {
} }
}; };
// Filter + sort rows const page = Math.floor(offset / PAGE_SIZE) + 1;
const filtered = (() => { const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
let result = rows.filter((r) => { const from = total === 0 ? 0 : offset + 1;
const qty = r.stock?.availableQuantity ?? 0; const to = Math.min(offset + PAGE_SIZE, total);
if (filter === 'in_stock') return qty >= 5;
if (filter === 'low_stock') return qty > 0 && qty < 5;
if (filter === 'out_of_stock') return qty === 0;
if (filter === 'expiring') {
return r.expirationDate !== null && daysUntil(r.expirationDate) <= 30;
}
if (filter === 'low_margin') {
const margin = marginPercent(r.price);
return margin !== null && margin < 30;
}
return true;
});
if (filter === 'expiring') {
result = [...result].sort(
(a, b) => daysUntil(a.expirationDate ?? '') - daysUntil(b.expirationDate ?? ''),
);
} else if (filter === 'low_margin') {
result = [...result].sort(
(a, b) => (marginPercent(a.price) ?? 0) - (marginPercent(b.price) ?? 0),
);
}
return result;
})();
const inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length;
const lowStockCount = rows.filter((r) => {
const q = r.stock?.availableQuantity ?? 0;
return q > 0 && q < 5;
}).length;
const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1> <h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
<p className="text-sm text-gray-500 mt-0.5">{rows.length} productos</p> <p className="text-sm text-gray-500 mt-0.5">{total} variantes</p>
</div> </div>
</div> </div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{[ {[
{ label: 'En stock', count: inStockCount, cls: 'bg-green-50 border-green-100 text-green-700' }, {
{ label: 'Stock bajo', count: lowStockCount, cls: 'bg-amber-50 border-amber-100 text-amber-700' }, label: 'En stock',
{ label: 'Sin stock', count: outOfStockCount, cls: 'bg-red-50 border-red-100 text-red-700' }, count: stats.inStock,
cls: 'bg-green-50 border-green-100 text-green-700',
},
{
label: 'Stock bajo',
count: stats.lowStock,
cls: 'bg-amber-50 border-amber-100 text-amber-700',
},
{
label: 'Sin stock',
count: stats.outOfStock,
cls: 'bg-red-50 border-red-100 text-red-700',
},
].map(({ label, count, cls }) => ( ].map(({ label, count, cls }) => (
<div key={label} className={`p-4 rounded-xl border ${cls}`}> <div key={label} className={`p-4 rounded-xl border ${cls}`}>
<p className="text-2xl font-bold">{count}</p> <p className="text-2xl font-bold">{count}</p>
@@ -320,48 +274,50 @@ export default function InventoryPage() {
))} ))}
</div> </div>
{/* Search + filters */}
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<div className="relative flex-1 max-w-sm min-w-52"> <div className="relative flex-1 max-w-sm min-w-52">
<input <input
type="search" type="search"
id="admin-inventory-search" id="admin-inventory-search"
name="q" name="q"
placeholder="Buscar por EAN o nombre..." placeholder="Buscar por nombre, SKU o EAN..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/> />
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /> className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg> </svg>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{(Object.keys(FILTER_LABELS) as RowFilter[]).map((f) => ( {(Object.keys(FILTER_LABELS) as RowFilter[]).map((currentFilter) => (
<button <button
key={f} key={currentFilter}
onClick={() => setFilter(f)} onClick={() => setFilter(currentFilter)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
filter === f filter === currentFilter
? 'bg-[#2D6A4F] text-white' ? 'bg-[#2D6A4F] text-white'
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50' : 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
}`} }`}
> >
{FILTER_LABELS[f]} {FILTER_LABELS[currentFilter]}
</button> </button>
))} ))}
</div> </div>
<button <button onClick={() => void load()} className="text-sm text-[#2D6A4F] hover:underline">
onClick={load}
className="text-sm text-[#2D6A4F] hover:underline"
>
Recargar Recargar
</button> </button>
</div> </div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? ( {loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400"> <div className="p-12 flex items-center justify-center gap-3 text-gray-400">
@@ -369,58 +325,104 @@ export default function InventoryPage() {
<span className="text-sm">Cargando inventario...</span> <span className="text-sm">Cargando inventario...</span>
</div> </div>
) : error ? ( ) : error ? (
<div className="text-center"> <div className="p-12 text-center">
<p className="text-red-600 text-sm mb-3">{error}</p> <p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button> <button onClick={() => void load()} className="text-sm text-[#2D6A4F] hover:underline">
Reintentar
</button>
</div> </div>
) : filtered.length === 0 ? ( ) : rows.length === 0 ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<p className="text-4xl mb-3">📦</p> <p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">No hay productos para este filtro</p> <p className="text-gray-500 text-sm">No hay productos para este filtro</p>
</div> </div>
) : ( ) : (
<>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left"> <tr className="bg-gray-50 border-b border-gray-200 text-left">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Producto</th> <th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th> Producto
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Caducidad</th> </th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Margen</th> <th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th> SKU / EAN
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th> </th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Tienda</th> <th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
Caducidad
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
Margen
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
Stock
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
Estado
</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">
Tienda
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{filtered.map((row) => { {rows.map((row) => (
const margin = marginPercent(row.price);
return (
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors"> <tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="text-sm font-medium text-gray-900">{row.productName}</p> <p className="text-sm font-medium text-gray-900">{row.productName}</p>
<p className="text-xs text-gray-400">{row.variant.sku}</p>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="space-y-1">
<p className="font-mono text-xs text-gray-500">SKU: {row.variant.sku}</p>
{row.editingEan ? ( {row.editingEan ? (
<input <input
autoFocus autoFocus
value={row.editEanValue} value={row.editEanValue}
onChange={e => setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editEanValue: e.target.value } : r))} onChange={(e) =>
onBlur={() => handleSaveEan(row.variant.id, row.productId, row.editEanValue)} setRows((prev) =>
onKeyDown={e => { if (e.key === 'Enter') handleSaveEan(row.variant.id, row.productId, row.editEanValue); if (e.key === 'Escape') setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editingEan: false } : r)); }} prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, editEanValue: e.target.value }
: r,
),
)
}
onBlur={() => void handleSaveEan(row.variant.id, row.productId, row.editEanValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleSaveEan(row.variant.id, row.productId, row.editEanValue);
}
if (e.key === 'Escape') {
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, editingEan: false } : r,
),
);
}
}}
disabled={row.savingEan} disabled={row.savingEan}
placeholder="" placeholder="EAN"
className="w-full px-2 py-1 border border-[#2D6A4F] rounded text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50" className="w-full px-2 py-1 border border-[#2D6A4F] rounded text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
/> />
) : ( ) : (
<button <button
onClick={() => setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editingEan: true, editEanValue: row.variant.ean ?? '' } : r))} onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, editingEan: true, editEanValue: row.variant.ean ?? '' }
: r,
),
)
}
className="font-mono text-xs text-gray-400 hover:text-[#2D6A4F] cursor-text text-left"
title="Clic para editar EAN" title="Clic para editar EAN"
className="font-mono text-xs text-gray-400 hover:text-[#2D6A4F] cursor-text text-left w-full truncate block disabled:opacity-50"
> >
{row.variant.ean ?? '—'} EAN: {row.variant.ean ?? '—'}
</button> </button>
)} )}
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{row.editingExp ? ( {row.editingExp ? (
@@ -437,15 +439,18 @@ export default function InventoryPage() {
), ),
) )
} }
onBlur={() => handleSaveExp(row.variant.id, row.productId, row.editExpValue)} onBlur={() => void handleSaveExp(row.variant.id, row.productId, row.editExpValue)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveExp(row.variant.id, row.productId, row.editExpValue); if (e.key === 'Enter') {
if (e.key === 'Escape') void handleSaveExp(row.variant.id, row.productId, row.editExpValue);
}
if (e.key === 'Escape') {
setRows((prev) => setRows((prev) =>
prev.map((r) => prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, editingExp: false } : r, r.variant.id === row.variant.id ? { ...r, editingExp: false } : r,
), ),
); );
}
}} }}
disabled={row.savingExp} disabled={row.savingExp}
className="px-2 py-1 border border-[#2D6A4F] rounded text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50" className="px-2 py-1 border border-[#2D6A4F] rounded text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
@@ -467,19 +472,24 @@ export default function InventoryPage() {
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{margin === null ? ( {row.marginPercent === null ? (
<span className="text-gray-300"></span> <span className="text-gray-300"></span>
) : ( ) : (
<span className={`text-xs font-bold ${margin >= 30 ? 'text-green-600' : margin >= 10 ? 'text-amber-600' : 'text-red-600'}`}> <span
{margin}% className={`text-xs font-bold ${
row.marginPercent >= 30
? 'text-green-600'
: row.marginPercent >= 10
? 'text-amber-600'
: 'text-red-600'
}`}
>
{row.marginPercent}%
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{row.loading ? ( {row.editing ? (
<span className="text-gray-300"></span>
) : row.editing ? (
<div className="flex items-center gap-2">
<input <input
autoFocus autoFocus
type="number" type="number"
@@ -494,51 +504,48 @@ export default function InventoryPage() {
), ),
) )
} }
onBlur={() => saveStockInline(row.variant.id, row.editValue)} onBlur={() => void saveStockInline(row.variant.id, row.editValue)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') saveStockInline(row.variant.id, row.editValue); if (e.key === 'Enter') {
if (e.key === 'Escape') void saveStockInline(row.variant.id, row.editValue);
}
if (e.key === 'Escape') {
setRows((prev) => setRows((prev) =>
prev.map((r) => prev.map((r) =>
r.variant.id === row.variant.id r.variant.id === row.variant.id
? { ? {
...r, ...r,
editing: false, editing: false,
editValue: String(r.stock?.availableQuantity ?? 0), editValue: String(r.stock.availableQuantity),
} }
: r, : r,
), ),
); );
}
}} }}
disabled={row.saving} disabled={row.saving}
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none" className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
/> />
{row.msg && (
<span className={`text-xs ${row.msg === '✓' || row.msg === '✓ Guardado' ? 'text-green-600' : 'text-red-600'}`}>
{row.msg}
</span>
)}
</div>
) : ( ) : (
<button <button
onClick={() => onClick={() =>
setRows((prev) => setRows((prev) =>
prev.map((r) => prev.map((r) =>
r.variant.id === row.variant.id r.variant.id === row.variant.id
? { ...r, editing: true, editValue: String(r.stock?.availableQuantity ?? 0) } ? { ...r, editing: true, editValue: String(r.stock.availableQuantity) }
: r, : r,
), ),
) )
} }
className="font-medium text-gray-900 hover:text-[#2D6A4F] cursor-text"
title="Clic para editar stock" title="Clic para editar stock"
className="font-medium text-gray-900 hover:text-[#2D6A4F] cursor-text disabled:opacity-50"
> >
{row.stock?.availableQuantity ?? '—'} {row.stock.availableQuantity}
</button> </button>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<StockBadge qty={row.stock?.availableQuantity ?? 0} /> <StockBadge qty={row.stock.availableQuantity} />
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{row.productActive ? ( {row.productActive ? (
@@ -550,8 +557,19 @@ export default function InventoryPage() {
title="Ver en la tienda" title="Ver en la tienda"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-green-50 text-green-600 transition-colors hover:bg-green-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F]" className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-green-50 text-green-600 transition-colors hover:bg-green-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F]"
> >
<svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4M14 4h6m0 0v6m0-6L10 14" /> aria-hidden="true"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</a> </a>
) : ( ) : (
@@ -559,11 +577,38 @@ export default function InventoryPage() {
)} )}
</td> </td>
</tr> </tr>
); ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-500">
<p>
Mostrando {from}-{to} de {total}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setOffset((current) => Math.max(0, current - PAGE_SIZE))}
disabled={offset === 0}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Anterior
</button>
<span>
Página {page} / {totalPages}
</span>
<button
type="button"
onClick={() => setOffset((current) => current + PAGE_SIZE)}
disabled={offset + PAGE_SIZE >= total}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
>
Siguiente
</button>
</div>
</div>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -9,9 +9,10 @@ const STATE_LABELS: Record<OrderState, string> = {
PENDING: 'Pendiente', PENDING: 'Pendiente',
AWAITING_PAYMENT: 'Esperando pago', AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagado', PAID: 'Pagado',
PROCESSING: 'Procesando', PROCESSING: 'En preparación',
SHIPPED: 'Enviado', SHIPPED: 'Enviado',
DELIVERED: 'Entregado', DELIVERED: 'Entregado',
COMPLETED: 'completado',
CANCELLED: 'Cancelado', CANCELLED: 'Cancelado',
REFUNDED: 'Reembolsado', REFUNDED: 'Reembolsado',
PARTIALLY_REFUNDED: 'Reembolso parcial', PARTIALLY_REFUNDED: 'Reembolso parcial',
@@ -24,6 +25,7 @@ const STATE_COLORS: Record<OrderState, string> = {
PROCESSING: 'bg-indigo-100 text-indigo-800', PROCESSING: 'bg-indigo-100 text-indigo-800',
SHIPPED: 'bg-purple-100 text-purple-800', SHIPPED: 'bg-purple-100 text-purple-800',
DELIVERED: 'bg-green-100 text-green-800', DELIVERED: 'bg-green-100 text-green-800',
COMPLETED: 'bg-emerald-100 text-emerald-800',
CANCELLED: 'bg-red-100 text-red-800', CANCELLED: 'bg-red-100 text-red-800',
REFUNDED: 'bg-purple-100 text-purple-800', REFUNDED: 'bg-purple-100 text-purple-800',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800', PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
@@ -32,10 +34,11 @@ const STATE_COLORS: Record<OrderState, string> = {
const ALLOWED_TRANSITIONS: Record<OrderState, OrderState[]> = { const ALLOWED_TRANSITIONS: Record<OrderState, OrderState[]> = {
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'], PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
AWAITING_PAYMENT: ['PAID', 'CANCELLED'], AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['PROCESSING', 'CANCELLED', 'REFUNDED'], PAID: ['PROCESSING', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['PAID', 'SHIPPED', '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: ['REFUNDED', 'PARTIALLY_REFUNDED'],
CANCELLED: [], CANCELLED: [],
REFUNDED: [], REFUNDED: [],
PARTIALLY_REFUNDED: [], PARTIALLY_REFUNDED: [],
@@ -48,11 +51,13 @@ const ACTION_LABELS_BY_TRANSITION: Record<string, string> = {
'PENDING>CANCELLED': 'Cancelar pedido', 'PENDING>CANCELLED': 'Cancelar pedido',
'AWAITING_PAYMENT>PAID': 'Marcar como Pagado', 'AWAITING_PAYMENT>PAID': 'Marcar como Pagado',
'AWAITING_PAYMENT>CANCELLED': 'Cancelar pedido', 'AWAITING_PAYMENT>CANCELLED': 'Cancelar pedido',
'PAID>PROCESSING': 'Procesar pedido', 'PAID>PROCESSING': 'Preparar pedido',
'PAID>COMPLETED': 'Marcar como completado',
'PAID>CANCELLED': 'Cancelar pedido', 'PAID>CANCELLED': 'Cancelar pedido',
'PAID>REFUNDED': 'Reembolsar', 'PAID>REFUNDED': 'Reembolsar',
'PROCESSING>PAID': 'Revertir a Pagado', 'PROCESSING>PAID': 'Revertir a Pagado',
'PROCESSING>SHIPPED': 'Marcar como Enviado', 'PROCESSING>SHIPPED': 'Marcar como Enviado',
'PROCESSING>COMPLETED': 'Marcar como completado',
'PROCESSING>CANCELLED': 'Cancelar pedido', 'PROCESSING>CANCELLED': 'Cancelar pedido',
'PROCESSING>REFUNDED': 'Reembolsar', 'PROCESSING>REFUNDED': 'Reembolsar',
'SHIPPED>PROCESSING': 'Revertir a En preparación', 'SHIPPED>PROCESSING': 'Revertir a En preparación',
@@ -60,6 +65,8 @@ const ACTION_LABELS_BY_TRANSITION: Record<string, string> = {
'SHIPPED>PARTIALLY_REFUNDED': 'Reembolso parcial', 'SHIPPED>PARTIALLY_REFUNDED': 'Reembolso parcial',
'DELIVERED>SHIPPED': 'Revertir a Enviado', 'DELIVERED>SHIPPED': 'Revertir a Enviado',
'DELIVERED>PARTIALLY_REFUNDED': 'Reembolso parcial', 'DELIVERED>PARTIALLY_REFUNDED': 'Reembolso parcial',
'COMPLETED>REFUNDED': 'Reembolsar',
'COMPLETED>PARTIALLY_REFUNDED': 'Reembolso parcial',
}; };
function actionLabel(from: OrderState, to: OrderState): string { function actionLabel(from: OrderState, to: OrderState): string {
@@ -78,6 +85,35 @@ function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`; 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() { export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [order, setOrder] = useState<Order | null>(null); const [order, setOrder] = useState<Order | null>(null);
@@ -706,18 +742,31 @@ export default function OrderDetailPage() {
</p> </p>
</div> </div>
</div> </div>
{[...history].reverse().map((event) => ( {[...history].reverse().map((event) => {
// 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 key={event.id} className="flex gap-3">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${event.eventType === 'SHIPPING_UPDATE' ? 'bg-purple-400' : 'bg-blue-400'}`} /> <div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
isRefundEvent ? 'bg-pink-500' :
event.eventType === 'SHIPPING_UPDATE' ? 'bg-purple-400' : 'bg-blue-400'
}`} />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-gray-800 break-words">{event.message}</p> <p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}>
{isRefundEvent && <span className="mr-1">💸</span>}{formattedMessage}
</p>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
{new Date(event.createdAt).toLocaleString('es-ES')} {new Date(event.createdAt).toLocaleString('es-ES')}
{event.actorEmail ? ` · ${event.actorEmail}` : ''} {event.actorEmail ? ` · ${event.actorEmail}` : ''}
</p> </p>
</div> </div>
</div> </div>
))} );
})}
{history.length === 0 && ( {history.length === 0 && (
<p className="text-xs text-gray-400">Sin eventos registrados todavía.</p> <p className="text-xs text-gray-400">Sin eventos registrados todavía.</p>
)} )}

View File

@@ -11,6 +11,7 @@ const ORDER_STATES: OrderState[] = [
'PROCESSING', 'PROCESSING',
'SHIPPED', 'SHIPPED',
'DELIVERED', 'DELIVERED',
'COMPLETED',
'CANCELLED', 'CANCELLED',
'REFUNDED', 'REFUNDED',
'PARTIALLY_REFUNDED', 'PARTIALLY_REFUNDED',
@@ -20,9 +21,10 @@ const STATE_LABELS: Record<OrderState, string> = {
PENDING: 'Pendiente', PENDING: 'Pendiente',
AWAITING_PAYMENT: 'Esperando pago', AWAITING_PAYMENT: 'Esperando pago',
PAID: 'Pagado', PAID: 'Pagado',
PROCESSING: 'Procesando', PROCESSING: 'En preparación',
SHIPPED: 'Enviado', SHIPPED: 'Enviado',
DELIVERED: 'Entregado', DELIVERED: 'Entregado',
COMPLETED: 'completado',
CANCELLED: 'Cancelado', CANCELLED: 'Cancelado',
REFUNDED: 'Reembolsado', REFUNDED: 'Reembolsado',
PARTIALLY_REFUNDED: 'Reembolso parcial', PARTIALLY_REFUNDED: 'Reembolso parcial',
@@ -35,6 +37,7 @@ const STATE_COLORS: Record<OrderState, string> = {
PROCESSING: 'bg-indigo-100 text-indigo-800', PROCESSING: 'bg-indigo-100 text-indigo-800',
SHIPPED: 'bg-purple-100 text-purple-800', SHIPPED: 'bg-purple-100 text-purple-800',
DELIVERED: 'bg-green-100 text-green-800', DELIVERED: 'bg-green-100 text-green-800',
COMPLETED: 'bg-emerald-100 text-emerald-800',
CANCELLED: 'bg-red-100 text-red-800', CANCELLED: 'bg-red-100 text-red-800',
REFUNDED: 'bg-purple-100 text-purple-800', REFUNDED: 'bg-purple-100 text-purple-800',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800', PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
@@ -62,6 +65,9 @@ export default function OrdersPage() {
const [filterState, setFilterState] = useState(''); const [filterState, setFilterState] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const limit = 20;
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400); const t = setTimeout(() => setDebouncedSearch(search), 400);
@@ -75,14 +81,21 @@ export default function OrdersPage() {
const data = await ordersApi.list({ const data = await ordersApi.list({
status: filterState || undefined, status: filterState || undefined,
q: debouncedSearch || undefined, q: debouncedSearch || undefined,
limit: 20, limit,
offset,
}); });
setOrders(data.items); setOrders(data.items);
setTotal(data.total);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar'); setError(err instanceof Error ? err.message : 'Error al cargar');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [filterState, debouncedSearch, offset]);
// Reset to first page when filters change
useEffect(() => {
setOffset(0);
}, [filterState, debouncedSearch]); }, [filterState, debouncedSearch]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -92,7 +105,7 @@ export default function OrdersPage() {
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1> <h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
<p className="text-sm text-gray-500 mt-0.5">{orders?.length ?? 0} pedidos</p> <p className="text-sm text-gray-500 mt-0.5">{total} pedidos</p>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -203,6 +216,32 @@ export default function OrdersPage() {
</tbody> </tbody>
</table> </table>
)} )}
{/* Pagination footer */}
{!loading && !error && orders && orders.length > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500">
Mostrando {offset + 1}{Math.min(offset + limit, total)} de {total}
</p>
<div className="flex gap-1">
<button
type="button"
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-3 py-1.5 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white"
>
Anterior
</button>
<button
type="button"
onClick={() => setOffset(offset + limit)}
disabled={offset + limit >= total}
className="px-3 py-1.5 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white"
>
Siguiente
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import Link from 'next/link';
interface Stats { interface Stats {
ordersToday: number; ordersToday: number;
@@ -24,6 +25,7 @@ const STATE_LABELS: Record<string, string> = {
PROCESSING: 'Procesando', PROCESSING: 'Procesando',
SHIPPED: 'Enviados', SHIPPED: 'Enviados',
DELIVERED: 'Entregados', DELIVERED: 'Entregados',
COMPLETED: 'completados',
CANCELLED: 'Cancelados', CANCELLED: 'Cancelados',
REFUNDED: 'Reembolsados', REFUNDED: 'Reembolsados',
PARTIALLY_REFUNDED: 'Reembolso parcial', PARTIALLY_REFUNDED: 'Reembolso parcial',
@@ -36,6 +38,7 @@ const STATE_COLORS: Record<string, string> = {
PROCESSING: 'bg-blue-100 text-blue-700', PROCESSING: 'bg-blue-100 text-blue-700',
SHIPPED: 'bg-indigo-100 text-indigo-700', SHIPPED: 'bg-indigo-100 text-indigo-700',
DELIVERED: 'bg-emerald-100 text-emerald-700', DELIVERED: 'bg-emerald-100 text-emerald-700',
COMPLETED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-gray-100 text-gray-600', CANCELLED: 'bg-gray-100 text-gray-600',
REFUNDED: 'bg-red-100 text-red-700', REFUNDED: 'bg-red-100 text-red-700',
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700', PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700',
@@ -47,15 +50,17 @@ function KPICard({
sub, sub,
icon, icon,
trend, trend,
href,
}: { }: {
label: string; label: string;
value: string; value: string;
sub?: string; sub?: string;
icon: string; icon: string;
trend?: 'up' | 'down' | 'neutral'; trend?: 'up' | 'down' | 'neutral';
href?: string;
}) { }) {
return ( const inner = (
<div className="bg-white border border-gray-200 rounded-xl p-5"> <div className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md hover:border-[#2D6A4F]/30 transition-all cursor-pointer">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-sm font-medium text-gray-500">{label}</p> <p className="text-sm font-medium text-gray-500">{label}</p>
@@ -66,6 +71,7 @@ function KPICard({
</div> </div>
</div> </div>
); );
return href ? <Link href={href} className="block">{inner}</Link> : inner;
} }
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) { function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
@@ -146,7 +152,7 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<KPICard <KPICard
label="Pedidos hoy" label="Pedidos hoy"
value={String(stats.ordersToday)} value={String(stats.ordersToday)}
@@ -165,38 +171,24 @@ export default function DashboardPage() {
sub="En el catálogo" sub="En el catálogo"
icon="🌿" icon="🌿"
/> />
<KPICard
label="Sin stock"
value={String(stats.outOfStockVariants)}
sub="Variantes agotadas"
icon="⚠️"
/>
</div> </div>
{/* Secondary KPIs */} {/* Secondary KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-2 gap-4">
<KPICard <KPICard
label="Clientes nuevos" label="Clientes nuevos"
href="/customers"
value={String(stats.newCustomersThisMonth)} value={String(stats.newCustomersThisMonth)}
sub="Este mes" sub="Este mes"
icon="👥" icon="👥"
/> />
<KPICard <KPICard
label="Total pedidos" label="Total pedidos"
href="/orders"
value={String(totalOrders)} value={String(totalOrders)}
sub="En el sistema" sub="En el sistema"
icon="📋" icon="📋"
/> />
<KPICard
label="Alertas"
value={
stats.outOfStockVariants > 0
? `${stats.outOfStockVariants} sin stock`
: 'Sin alertas'
}
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
/>
</div> </div>
{/* Orders by state */} {/* Orders by state */}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
interface PosStore { interface PosStore {
@@ -28,6 +28,8 @@ interface CatalogOption {
variantId: string; variantId: string;
name: string; name: string;
sku: string; sku: string;
ean?: string | null;
priceCents?: number;
} }
interface PaymentMethod { interface PaymentMethod {
id: string; id: string;
@@ -57,6 +59,7 @@ interface ReceiptSettings {
contactPhone: string; contactPhone: string;
receiptHeader: string; receiptHeader: string;
receiptFooter: string; receiptFooter: string;
logoUrl: string;
prefix: string; prefix: string;
nextNumber: number; nextNumber: number;
padding: number; padding: number;
@@ -72,16 +75,22 @@ const emptyReceipt: ReceiptSettings = {
contactPhone: '', contactPhone: '',
receiptHeader: '', receiptHeader: '',
receiptFooter: '', receiptFooter: '',
logoUrl: '',
prefix: 'TPV', prefix: 'TPV',
nextNumber: 1, nextNumber: 1,
padding: 6, padding: 6,
returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.', returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.',
}; };
const QUICK_PRODUCTS_MAX = 8;
export default function PosAdminPage() { export default function PosAdminPage() {
const [stores, setStores] = useState<PosStore[]>([]); const [stores, setStores] = useState<PosStore[]>([]);
const [terminals, setTerminals] = useState<PosTerminal[]>([]); const [terminals, setTerminals] = useState<PosTerminal[]>([]);
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]); const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
const [quickSearch, setQuickSearch] = useState('');
const [quickSearchResults, setQuickSearchResults] = useState<CatalogOption[]>([]);
const [quickSearchLoading, setQuickSearchLoading] = useState(false);
const [storeId, setStoreId] = useState(''); const [storeId, setStoreId] = useState('');
const [terminalName, setTerminalName] = useState(''); const [terminalName, setTerminalName] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -93,7 +102,7 @@ export default function PosAdminPage() {
const [touchEnabled, setTouchEnabled] = useState(true); const [touchEnabled, setTouchEnabled] = useState(true);
const [discountsEnabled, setDiscountsEnabled] = useState(true); const [discountsEnabled, setDiscountsEnabled] = useState(true);
const [quickSlots, setQuickSlots] = useState<Array<string | null>>( const [quickSlots, setQuickSlots] = useState<Array<string | null>>(
Array.from({ length: 10 }, () => null), Array.from({ length: QUICK_PRODUCTS_MAX }, () => null),
); );
const [savingTouch, setSavingTouch] = useState(false); const [savingTouch, setSavingTouch] = useState(false);
const [touchMessage, setTouchMessage] = useState(''); const [touchMessage, setTouchMessage] = useState('');
@@ -119,6 +128,33 @@ export default function PosAdminPage() {
const [savingReceipt, setSavingReceipt] = useState(false); const [savingReceipt, setSavingReceipt] = useState(false);
const [receiptMessage, setReceiptMessage] = useState(''); const [receiptMessage, setReceiptMessage] = useState('');
const mergeCatalogOptions = useCallback((items: CatalogOption[]) => {
setCatalogOptions((current) => {
const merged = new Map(current.map((item) => [item.variantId, item]));
items.forEach((item) => merged.set(item.variantId, item));
return Array.from(merged.values());
});
}, []);
const catalogById = useMemo(
() => new Map(catalogOptions.map((item) => [item.variantId, item])),
[catalogOptions],
);
const fetchCatalogOptions = useCallback(
async (params: { q?: string; limit?: number; variantIds?: string[] } = {}) => {
const qs = new URLSearchParams();
if (params.q?.trim()) qs.set('q', params.q.trim());
if (params.limit) qs.set('limit', String(params.limit));
if (params.variantIds?.length) qs.set('variantIds', params.variantIds.join(','));
const suffix = qs.toString();
return api.get<{ items: CatalogOption[] }>(
`/api/pos/admin/catalog-products${suffix ? `?${suffix}` : ''}`,
);
},
[],
);
const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => { const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => {
if (!selectedStoreId) return; if (!selectedStoreId) return;
try { try {
@@ -141,15 +177,13 @@ export default function PosAdminPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [storeData, terminalData, catalogData, userData] = await Promise.all([ const [storeData, terminalData, userData] = await Promise.all([
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'), api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'), api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'), api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'),
]); ]);
setStores(storeData.stores); setStores(storeData.stores);
setTerminals(terminalData.terminals); setTerminals(terminalData.terminals);
setCatalogOptions(catalogData.items);
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier')); setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
const selected = storeId || storeData.stores.find((store) => store.active)?.id || ''; const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
setStoreId(selected); setStoreId(selected);
@@ -193,16 +227,83 @@ export default function PosAdminPage() {
}; };
const openTouchConfig = (terminal: PosTerminal) => { const openTouchConfig = (terminal: PosTerminal) => {
const configured = terminal.settings?.quickProductVariantIds ?? [];
const selectedIds = configured.filter((value): value is string => typeof value === 'string');
setConfiguring(terminal); setConfiguring(terminal);
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false); setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false); setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
const configured = terminal.settings?.quickProductVariantIds ?? []; setQuickSlots(Array.from({ length: QUICK_PRODUCTS_MAX }, (_, slot) => configured[slot] ?? null));
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null)); setQuickSearch('');
setQuickSearchResults([]);
setTouchMessage(''); setTouchMessage('');
setSelfpayMode(terminal.settings?.selfpayMode ?? false); setSelfpayMode(terminal.settings?.selfpayMode ?? false);
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false); setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
setClosePinValue(terminal.settings?.closeSessionPin ?? ''); setClosePinValue(terminal.settings?.closeSessionPin ?? '');
setSecurityMessage(''); setSecurityMessage('');
if (selectedIds.length > 0) {
void fetchCatalogOptions({ variantIds: selectedIds })
.then((data) => mergeCatalogOptions(data.items))
.catch((err) => {
setTouchMessage(err instanceof Error ? err.message : 'No se pudo cargar la selección actual');
});
}
};
useEffect(() => {
if (!configuring) return;
let cancelled = false;
const timer = setTimeout(() => {
void (async () => {
setQuickSearchLoading(true);
try {
const data = await fetchCatalogOptions({ q: quickSearch, limit: 12 });
if (cancelled) return;
mergeCatalogOptions(data.items);
setQuickSearchResults(data.items);
} catch (err) {
if (!cancelled) {
setTouchMessage(err instanceof Error ? err.message : 'No se pudo buscar productos');
}
} finally {
if (!cancelled) setQuickSearchLoading(false);
}
})();
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [configuring, fetchCatalogOptions, mergeCatalogOptions, quickSearch]);
const addQuickProduct = (product: CatalogOption) => {
if (quickSlots.includes(product.variantId)) {
setTouchMessage('Ese producto ya está en accesos rápidos');
return;
}
const emptySlot = quickSlots.findIndex((value) => value === null);
if (emptySlot === -1) {
setTouchMessage(`Solo puedes guardar ${QUICK_PRODUCTS_MAX} productos rápidos`);
return;
}
setQuickSlots((current) => current.map((value, index) => (index === emptySlot ? product.variantId : value)));
mergeCatalogOptions([product]);
setTouchMessage('');
};
const removeQuickProduct = (slot: number) => {
setQuickSlots((current) => current.map((value, index) => (index === slot ? null : value)));
setTouchMessage('');
};
const moveQuickProduct = (slot: number, direction: -1 | 1) => {
const target = slot + direction;
if (target < 0 || target >= QUICK_PRODUCTS_MAX) return;
setQuickSlots((current) => {
const next = [...current];
[next[slot], next[target]] = [next[target], next[slot]];
return next;
});
setTouchMessage('');
}; };
const saveTouchConfig = async () => { const saveTouchConfig = async () => {
@@ -615,30 +716,136 @@ export default function PosAdminPage() {
<p className="mt-2 text-xs text-gray-500"> <p className="mt-2 text-xs text-gray-500">
Desactiva descuentos para terminales de autopago. Desactiva descuentos para terminales de autopago.
</p> </p>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="mt-5 rounded-2xl border border-gray-200 bg-gray-50 p-4">
{quickSlots.map((variantId, slot) => ( <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<label key={slot} className="text-sm font-medium text-gray-700"> <div>
Producto rápido {slot + 1} <h3 className="text-sm font-bold text-gray-900">
<select Productos rápidos ({quickSlots.filter((value) => value !== null).length}/{QUICK_PRODUCTS_MAX})
value={variantId ?? ''} </h3>
onChange={(event) => <p className="mt-1 text-xs text-gray-500">
setQuickSlots((current) => Busca por nombre, SKU o EAN y añade productos a una lista de hasta ocho accesos rápidos.
current.map((value, index) => </p>
index === slot ? event.target.value || null : value, </div>
), <button
) type="button"
} onClick={() => void saveTouchConfig()}
className="mt-1 w-full rounded-xl border bg-white px-3 py-2.5" disabled={savingTouch}
className="rounded-xl bg-[#2D6A4F] px-5 py-2 text-sm font-semibold text-white disabled:opacity-50"
> >
<option value="">Sin asignar</option> {savingTouch ? 'Guardando…' : 'Guardar accesos rápidos'}
{catalogOptions.map((product) => ( </button>
<option key={product.variantId} value={product.variantId}> </div>
{product.name} · {product.sku}
</option> <label className="mt-4 block text-sm font-medium text-gray-700">
))} Buscar producto
</select> <input
type="search"
value={quickSearch}
onChange={(event) => setQuickSearch(event.target.value)}
placeholder="Ej. Kombucha, SKU123, 8412345678901"
className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5"
/>
</label> </label>
))}
<div className="mt-3 space-y-2">
{quickSearchLoading ? (
<p className="text-sm text-gray-500">Buscando productos</p>
) : quickSearchResults.length > 0 ? (
quickSearchResults.map((product) => {
const isSelected = quickSlots.includes(product.variantId);
const isFull = !isSelected && quickSlots.every((value) => value !== null);
return (
<div
key={product.variantId}
className="flex flex-col gap-2 rounded-xl border border-white bg-white p-3 shadow-sm md:flex-row md:items-center md:justify-between"
>
<div>
<p className="font-medium text-gray-900">{product.name}</p>
<p className="text-xs text-gray-500">
{product.sku}
{product.ean ? ` · EAN ${product.ean}` : ''}
</p>
</div>
<button
type="button"
onClick={() => addQuickProduct(product)}
disabled={isSelected || isFull}
className="rounded-lg border border-[#2D6A4F] px-3 py-2 text-sm font-semibold text-[#2D6A4F] disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-400"
>
{isSelected ? 'Añadido' : isFull ? 'Lista completa' : 'Añadir'}
</button>
</div>
);
})
) : (
<p className="text-sm text-gray-500">No se encontraron productos para esa búsqueda.</p>
)}
</div>
<div className="mt-4 grid gap-3">
{quickSlots.map((variantId, slot) => {
const product = variantId ? catalogById.get(variantId) : null;
return (
<div
key={slot}
className="flex flex-col gap-3 rounded-xl border border-white bg-white p-3 shadow-sm md:flex-row md:items-center md:justify-between"
>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Botón {slot + 1}
</p>
{variantId ? (
<>
<p className="font-medium text-gray-900">
{product?.name ?? 'Producto configurado previamente'}
</p>
<p className="text-xs text-gray-500">
{product?.sku ?? variantId}
{product?.ean ? ` · EAN ${product.ean}` : ''}
</p>
</>
) : (
<p className="text-sm text-gray-500">Sin asignar</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => moveQuickProduct(slot, -1)}
disabled={slot === 0 || variantId === null}
className="rounded-lg border bg-white px-3 py-2 text-xs font-semibold disabled:opacity-40"
>
Subir
</button>
<button
type="button"
onClick={() => moveQuickProduct(slot, 1)}
disabled={slot === QUICK_PRODUCTS_MAX - 1 || variantId === null}
className="rounded-lg border bg-white px-3 py-2 text-xs font-semibold disabled:opacity-40"
>
Bajar
</button>
<button
type="button"
onClick={() => removeQuickProduct(slot)}
disabled={variantId === null}
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-700 disabled:opacity-40"
>
Quitar
</button>
</div>
</div>
);
})}
</div>
{touchMessage && (
<p
className={`mt-3 text-sm ${touchMessage.includes('guardad') ? 'text-green-700' : 'text-red-600'}`}
>
{touchMessage}
</p>
)}
</div> </div>
{/* F-203: cash close security settings */} {/* F-203: cash close security settings */}
<div className="mt-6 border-t pt-4"> <div className="mt-6 border-t pt-4">
@@ -823,6 +1030,12 @@ export default function PosAdminPage() {
value={receipt.receiptFooter} value={receipt.receiptFooter}
onChange={(value) => setReceipt({ ...receipt, receiptFooter: value })} onChange={(value) => setReceipt({ ...receipt, receiptFooter: value })}
/> />
<Field
label="URL del logo"
value={receipt.logoUrl}
onChange={(value) => setReceipt({ ...receipt, logoUrl: value })}
placeholder="https://ejemplo.com/logo.png"
/>
<Field <Field
label="Prefijo de ticket" label="Prefijo de ticket"
value={receipt.prefix} value={receipt.prefix}
@@ -882,6 +1095,7 @@ function Field({
required = false, required = false,
min, min,
max, max,
placeholder,
}: { }: {
label: string; label: string;
value: string; value: string;
@@ -890,6 +1104,7 @@ function Field({
required?: boolean; required?: boolean;
min?: number; min?: number;
max?: number; max?: number;
placeholder?: string;
}) { }) {
return ( return (
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">
@@ -901,6 +1116,7 @@ function Field({
required={required} required={required}
min={min} min={min}
max={max} max={max}
placeholder={placeholder}
className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5" className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5"
/> />
</label> </label>

View File

@@ -2,11 +2,15 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { promotionsApi } from '@/lib/api-client'; import { promotionsApi } from '@/lib/api-client';
import { RowActions } from '@/components/ui/RowActions'; import { RowActions } from '@/components/ui/RowActions';
import Pagination from '@/components/ui/Pagination';
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; } interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
export default function PromotionsPage() { export default function PromotionsPage() {
const PAGE_SIZE = 20;
const [items, setItems] = useState<Promo[]>([]); const [items, setItems] = useState<Promo[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -22,11 +26,12 @@ export default function PromotionsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const d = await promotionsApi.list() as { items: Promo[] }; const d = await promotionsApi.list({ limit: PAGE_SIZE, offset }) as { items: Promo[]; total: number };
setItems(d.items ?? []); setItems(d.items ?? []);
setTotal(d.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } } catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, [offset]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -106,9 +111,14 @@ export default function PromotionsPage() {
)} )}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> : {loading ? (
error ? <div className="text-center text-red-600">{error}</div> : <div className="p-12 text-center text-gray-400">Cargando...</div>
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> : ) : error ? (
<div className="text-center text-red-600 p-12">{error}</div>
) : items.length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay promociones</div>
) : (
<>
<table className="w-full"> <table className="w-full">
<thead><tr className="bg-gray-50 border-b border-gray-200"> <thead><tr className="bg-gray-50 border-b border-gray-200">
{['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)} {['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)}
@@ -131,7 +141,9 @@ export default function PromotionsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
} <Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -13,18 +13,25 @@ interface SalesSummary {
byState: Record<string, { count: number; totalCents: number }>; byState: Record<string, { count: number; totalCents: number }>;
} }
interface ClosedSession { interface CashCloseReport {
session: {
id: string; id: string;
openedAt: string; openedAt: string;
closedAt: string; closedAt: string | null;
userId: string; userId: string;
status: string; status: string;
};
storeId: string;
terminalId: string;
financial: {
openingCashCents: number; openingCashCents: number;
closingCashCents: number | null; closingCashCents: number;
actualCashCents: number | null; actualCashCents: number;
differenceCents: number | null; expectedCashCents: number;
differenceCents: number;
};
sales: SalesSummary; sales: SalesSummary;
paymentsByMethod: Array<{ methodCode: string; methodName: string; totalCents: number; count: number }>; payments: Array<{ methodCode: string; methodName: string; totalCents: number; transactionCount: number }>;
items: { soldCount: number; uniqueProducts: number }; items: { soldCount: number; uniqueProducts: number };
} }
@@ -53,10 +60,11 @@ interface SessionRow {
export default function ReportingClosuresPage() { export default function ReportingClosuresPage() {
const [sessions, setSessions] = useState<SessionRow[]>([]); const [sessions, setSessions] = useState<SessionRow[]>([]);
const [report, setReport] = useState<ClosedSession | null>(null); const [report, setReport] = useState<CashCloseReport | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingReport, setLoadingReport] = useState(false); const [loadingReport, setLoadingReport] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [reportError, setReportError] = useState('');
const [selectedStore, setSelectedStore] = useState(''); const [selectedStore, setSelectedStore] = useState('');
const [stores, setStores] = useState<Array<{ id: string; name: string }>>([]); const [stores, setStores] = useState<Array<{ id: string; name: string }>>([]);
const [filterDays, setFilterDays] = useState(30); const [filterDays, setFilterDays] = useState(30);
@@ -85,11 +93,14 @@ export default function ReportingClosuresPage() {
const loadReport = async (sessionId: string) => { const loadReport = async (sessionId: string) => {
setLoadingReport(true); setLoadingReport(true);
try {
const data = await api.get<ClosedSession>(`/api/pos/reports/cash-close/${sessionId}`);
setReport(data);
} catch {
setReport(null); setReport(null);
setReportError('');
try {
const data = await api.get<CashCloseReport>(`/api/pos/reports/cash-close/${sessionId}`);
setReport(data);
} catch (err) {
setReport(null);
setReportError(err instanceof Error ? err.message : 'Error al cargar reporte de cierre');
} finally { } finally {
setLoadingReport(false); setLoadingReport(false);
} }
@@ -108,7 +119,7 @@ export default function ReportingClosuresPage() {
if (selectedStore) void loadSessions(); if (selectedStore) void loadSessions();
}, [selectedStore, filterDays, loadSessions]); }, [selectedStore, filterDays, loadSessions]);
const diff = report?.differenceCents ?? 0; const diff = report?.financial.differenceCents ?? 0;
const diffClass = diff > 0 ? 'text-green-600' : diff < 0 ? 'text-red-600' : 'text-gray-600'; const diffClass = diff > 0 ? 'text-green-600' : diff < 0 ? 'text-red-600' : 'text-gray-600';
return ( return (
@@ -163,7 +174,7 @@ export default function ReportingClosuresPage() {
type="button" type="button"
onClick={() => void loadReport(s.id)} onClick={() => void loadReport(s.id)}
className={`w-full text-left rounded-xl border p-3 text-sm transition-colors ${ className={`w-full text-left rounded-xl border p-3 text-sm transition-colors ${
report?.id === s.id report?.session.id === s.id
? 'border-[#2D6A4F] bg-[#2D6A4F]/5' ? 'border-[#2D6A4F] bg-[#2D6A4F]/5'
: 'border-gray-200 hover:border-[#2D6A4F] hover:bg-gray-50' : 'border-gray-200 hover:border-[#2D6A4F] hover:bg-gray-50'
}`} }`}
@@ -193,14 +204,14 @@ export default function ReportingClosuresPage() {
{/* Report detail */} {/* Report detail */}
<div className="lg:col-span-3"> <div className="lg:col-span-3">
{!report ? ( {loadingReport ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-dashed border-gray-300 text-gray-400 text-sm">
Selecciona una sesión para ver el reporte
</div>
) : loadingReport ? (
<div className="flex h-64 items-center justify-center text-gray-400 text-sm"> <div className="flex h-64 items-center justify-center text-gray-400 text-sm">
Cargando reporte Cargando reporte
</div> </div>
) : !report ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-dashed border-gray-300 px-6 text-center text-gray-400 text-sm">
{reportError || 'Selecciona una sesión para ver el reporte'}
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Financial summary */} {/* Financial summary */}
@@ -208,9 +219,9 @@ export default function ReportingClosuresPage() {
<h3 className="mb-4 text-base font-bold text-gray-900">📊 Resumen financiero</h3> <h3 className="mb-4 text-base font-bold text-gray-900">📊 Resumen financiero</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[ {[
['Saldo inicial', fmt(report.openingCashCents)], ['Saldo inicial', fmt(report.financial.openingCashCents)],
['Ventas', fmt(report.sales.completedTotalCents)], ['Ventas', fmt(report.sales.completedTotalCents)],
['Saldo esperado', fmt(report.openingCashCents + report.sales.completedTotalCents)], ['Saldo esperado', fmt(report.financial.expectedCashCents)],
].map(([label, value]) => ( ].map(([label, value]) => (
<div key={label}> <div key={label}>
<p className="text-xs text-gray-500">{label}</p> <p className="text-xs text-gray-500">{label}</p>
@@ -219,7 +230,7 @@ export default function ReportingClosuresPage() {
))} ))}
<div> <div>
<p className="text-xs text-gray-500">Efectivo real</p> <p className="text-xs text-gray-500">Efectivo real</p>
<p className="text-lg font-bold text-gray-900">{fmt(report.actualCashCents ?? 0)}</p> <p className="text-lg font-bold text-gray-900">{fmt(report.financial.actualCashCents)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-gray-500">Diferencia</p> <p className="text-xs text-gray-500">Diferencia</p>
@@ -257,7 +268,7 @@ export default function ReportingClosuresPage() {
</div> </div>
{/* By payment method */} {/* By payment method */}
{report.paymentsByMethod.length > 0 && ( {report.payments.length > 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-5"> <div className="rounded-xl border border-gray-200 bg-white p-5">
<h3 className="mb-3 text-base font-bold text-gray-900">💳 Formas de pago</h3> <h3 className="mb-3 text-base font-bold text-gray-900">💳 Formas de pago</h3>
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -269,10 +280,10 @@ export default function ReportingClosuresPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{report.paymentsByMethod.map(p => ( {report.payments.map(p => (
<tr key={p.methodCode} className="border-b border-gray-50 last:border-0"> <tr key={p.methodCode} className="border-b border-gray-50 last:border-0">
<td className="py-2 font-medium text-gray-800">{p.methodName}</td> <td className="py-2 font-medium text-gray-800">{p.methodName}</td>
<td className="py-2 text-right text-gray-600">{p.count}</td> <td className="py-2 text-right text-gray-600">{p.transactionCount}</td>
<td className="py-2 text-right font-bold text-gray-900">{fmt(p.totalCents)}</td> <td className="py-2 text-right font-bold text-gray-900">{fmt(p.totalCents)}</td>
</tr> </tr>
))} ))}

View File

@@ -79,7 +79,7 @@ function TrendDashboard({ filters }: { filters: FilterState }) {
const maxValue = Math.max(...points.map((p) => p.value), 1); const maxValue = Math.max(...points.map((p) => p.value), 1);
return <TrendChart data={points} maxValue={maxValue} height={140} />; return <TrendChart data={points} maxValue={maxValue} height={180} />;
} }
function ChannelDashboard({ filters }: { filters: FilterState }) { function ChannelDashboard({ filters }: { filters: FilterState }) {

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { reviewsApi } from '@/lib/api-client'; import { reviewsApi } from '@/lib/api-client';
import Pagination from '@/components/ui/Pagination';
interface Review { interface Review {
id: string; productId: string; userId: string; orderId: string; id: string; productId: string; userId: string; orderId: string;
@@ -23,8 +24,10 @@ function Stars({ n }: { n: number }) {
} }
export default function ReviewsPage() { export default function ReviewsPage() {
const PAGE_SIZE = 20;
const [items, setItems] = useState<Review[]>([]); const [items, setItems] = useState<Review[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [filter, setFilter] = useState<string>(''); const [filter, setFilter] = useState<string>('');
@@ -32,14 +35,15 @@ export default function ReviewsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: 50 }) as { items: Review[]; total: number }; const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: PAGE_SIZE, offset }) as { items: Review[]; total: number };
setItems(d.items ?? []); setItems(d.items ?? []);
setTotal(d.total ?? 0); setTotal(d.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } } catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); } finally { setLoading(false); }
}, [filter]); }, [filter, offset]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { setOffset(0); }, [filter]);
const moderate = async (id: string, status: 'published' | 'rejected') => { const moderate = async (id: string, status: 'published' | 'rejected') => {
try { try {
@@ -62,10 +66,15 @@ export default function ReviewsPage() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> : {loading ? (
error ? <div className="text-center text-red-600">{error}</div> : <div className="p-12 text-center text-gray-400">Cargando...</div>
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay reseñas</div> : ) : error ? (
items.map(r => ( <div className="text-center text-red-600 p-12">{error}</div>
) : items.length === 0 ? (
<div className="p-12 text-center text-gray-400">No hay reseñas</div>
) : (
<>
{items.map(r => (
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5"> <div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
@@ -88,8 +97,10 @@ export default function ReviewsPage() {
</button> </button>
)} )}
</div> </div>
)) ))}
} <Pagination total={total} offset={offset} limit={PAGE_SIZE} onOffsetChange={setOffset} />
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { settingsApi, type StoreSettings } from '@/lib/api-client'; import { settingsApi, type AboutInfo, type StoreSettings } from '@/lib/api-client';
type FormData = StoreSettings; type FormData = StoreSettings;
@@ -12,6 +12,7 @@ const TABS = [
{ id: 'ai', label: 'IA para SEO', icon: '✨' }, { id: 'ai', label: 'IA para SEO', icon: '✨' },
{ id: 'smtp', label: 'SMTP / Email', icon: '✉️' }, { id: 'smtp', label: 'SMTP / Email', icon: '✉️' },
{ id: 'couriers', label: 'Transportistas', icon: '🚚' }, { id: 'couriers', label: 'Transportistas', icon: '🚚' },
{ id: 'about', label: 'About', icon: '' },
] as const; ] as const;
type TabId = (typeof TABS)[number]['id']; type TabId = (typeof TABS)[number]['id'];
@@ -24,12 +25,28 @@ export default function SettingsPage() {
const [err, setErr] = useState(''); const [err, setErr] = useState('');
const [tab, setTab] = useState<TabId>('general'); const [tab, setTab] = useState<TabId>('general');
const [couriersText, setCouriersText] = useState(''); const [couriersText, setCouriersText] = useState('');
const [about, setAbout] = useState<AboutInfo | null>(null);
const [aboutLoading, setAboutLoading] = useState(false);
const loadAbout = async () => {
setAboutLoading(true);
try {
setAbout(await settingsApi.about());
} catch {
setErr('Error al cargar información del sistema');
} finally {
setAboutLoading(false);
}
};
useEffect(() => { useEffect(() => {
Promise.all([
settingsApi.get().then(d => { settingsApi.get().then(d => {
setData(d); setForm(d); setData(d); setForm(d);
setCouriersText((d.couriers ?? []).join('\n')); setCouriersText((d.couriers ?? []).join('\n'));
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false)); }),
loadAbout(),
]).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
}, []); }, []);
const handleCouriersChange = (text: string) => { const handleCouriersChange = (text: string) => {
@@ -38,6 +55,22 @@ export default function SettingsPage() {
setForm(f => f ? { ...f, couriers: list } : f); setForm(f => f ? { ...f, couriers: list } : f);
}; };
const formatDuration = (seconds: number) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const infoRow = (label: string, value: string | number) => (
<div className="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">{label}</p>
<p className="mt-1 break-words text-sm font-semibold text-gray-800">{value}</p>
</div>
);
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!form) return; if (!form) return;
@@ -153,6 +186,8 @@ export default function SettingsPage() {
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })} {field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })} {field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
{field('twitterUrl', 'X (Twitter)', { placeholder: 'https://x.com/...' })}
{field('pinterestUrl', 'Pinterest', { placeholder: 'https://pinterest.com/...' })}
</div> </div>
</> </>
)} )}
@@ -226,6 +261,81 @@ export default function SettingsPage() {
</> </>
)} )}
{tab === 'about' && (
<>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-gray-800">About / Sistema</h2>
<p className="text-xs text-gray-400 mt-0.5">Versiones de servicios e información segura del servidor.</p>
</div>
<button
type="button"
onClick={() => void loadAbout()}
disabled={aboutLoading}
className="rounded-xl border border-gray-300 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-white disabled:opacity-50"
>
{aboutLoading ? 'Actualizando…' : 'Actualizar'}
</button>
</div>
<div className="p-6 space-y-6">
{about ? (
<>
<div className="rounded-2xl border border-[#2D6A4F]/20 bg-[#2D6A4F]/5 p-5">
<p className="text-xs font-medium uppercase tracking-wide text-[#2D6A4F]">Versión producto</p>
<p className="mt-1 text-3xl font-bold text-[#1B4332]">v{about.productVersion}</p>
<p className="mt-2 text-xs text-gray-500">Generado: {new Date(about.generatedAt).toLocaleString('es-ES')}</p>
</div>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Servicios</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{about.services.map(service => (
<div key={service.id} className="rounded-xl border border-gray-200 p-4">
<p className="text-sm font-semibold text-gray-900">{service.name}</p>
<p className="mt-1 font-mono text-xs text-gray-500">{service.id}</p>
<p className="mt-3 inline-flex rounded-full bg-gray-100 px-2.5 py-1 font-mono text-xs font-semibold text-gray-700">v{service.version}</p>
</div>
))}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Servidor</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{infoRow('Host', about.server.hostname)}
{infoRow('Sistema', `${about.server.platform} ${about.server.arch}`)}
{infoRow('Kernel / Release', about.server.release)}
{infoRow('Node.js', about.server.nodeVersion)}
{infoRow('PID backend', about.server.pid)}
{infoRow('Zona horaria', about.server.timezone)}
{infoRow('Uptime host', formatDuration(about.server.uptimeSeconds))}
{infoRow('Uptime proceso', formatDuration(about.server.processUptimeSeconds))}
{infoRow('Arranque proceso', new Date(about.server.processStartedAt).toLocaleString('es-ES'))}
{infoRow('CPU cores', about.server.cpuCount)}
{infoRow('RAM total', `${about.server.totalMemoryMb} MB`)}
{infoRow('RAM libre', `${about.server.freeMemoryMb} MB`)}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-gray-800">Proceso y base de datos</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{infoRow('RSS proceso', `${about.server.processMemoryMb.rss} MB`)}
{infoRow('Heap usado', `${about.server.processMemoryMb.heapUsed} MB`)}
{infoRow('Heap total', `${about.server.processMemoryMb.heapTotal} MB`)}
{infoRow('PostgreSQL', about.database.postgresVersion)}
</div>
</section>
</>
) : (
<div className="rounded-xl border border-gray-200 p-8 text-center text-sm text-gray-500">
{aboutLoading ? 'Cargando información del sistema…' : 'No se pudo cargar la información del sistema.'}
</div>
)}
</div>
</>
)}
{tab === 'footer' && ( {tab === 'footer' && (
<> <>
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200"> <div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
@@ -242,12 +352,14 @@ export default function SettingsPage() {
</> </>
)} )}
{tab !== 'about' && (
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end"> <div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
<button type="submit" disabled={saving || !form} <button type="submit" disabled={saving || !form}
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"> className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
{saving ? 'Guardando...' : 'Guardar cambios'} {saving ? 'Guardando...' : 'Guardar cambios'}
</button> </button>
</div> </div>
)}
</form> </form>
</div> </div>
)} )}

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'; const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/** const PROXY_TIMEOUT_MS = 25_000;
* Catch-all proxy: forwards ALL requests to the backend API. const REQUEST_ID_HEADER = 'x-request-id';
* This avoids CORS preflight issues since requests stay within the
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend). interface ProxyInit {
* method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
* More specific routes (e.g. /api/auth/login) take precedence in Next.js, /** Raw body, or `null` when there is no body to forward. */
* so they are NOT served by this handler. body: string | null;
*/ headers: Record<string, string>;
export async function GET(req: NextRequest) { requestId: string;
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 });
}
} }
export async function POST(req: NextRequest) { interface ProxyResult {
const path = req.nextUrl.pathname.replace('/api/', ''); status: number;
const search = req.nextUrl.search; headers: Headers;
const cookies = req.headers.get('cookie') ?? ''; body: ReadableStream | null;
const body = await req.text(); }
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 { try {
const headers: Record<string, string> = { Cookie: cookies }; const backendRes = await fetch(url, {
if (body) headers['Content-Type'] = 'application/json'; method: init.method,
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'POST',
headers, 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 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) { if (setCookie) {
resp.headers.set( responseHeaders.set(
'Set-Cookie', 'Set-Cookie',
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(), setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
); );
} }
return resp; return {
} 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, {
status: backendRes.status, status: backendRes.status,
statusText: backendRes.statusText, headers: responseHeaders,
headers: { 'Content-Length': backendRes.headers.get('content-length') ?? '0' }, 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',
}); });
} catch { }
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
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

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ordersApi, type StaleOrderNotification } from '@/lib/api-client'; import { ordersApi, inventoryApi, type StaleOrderNotification } from '@/lib/api-client';
const POLL_INTERVAL_MS = 5 * 60 * 1000; const POLL_INTERVAL_MS = 5 * 60 * 1000;
@@ -18,26 +18,39 @@ function money(cents: number): string {
type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string }; type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string };
type InventoryNotificationItem = {
id: 'low_stock' | 'out_of_stock' | 'expired' | 'expiring_soon' | 'low_margin';
count: number;
href: string;
label: string;
tone: 'amber' | 'red';
};
export function OrderNotifications() { export function OrderNotifications() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [staleItems, setStaleItems] = useState<StaleOrderNotification[]>([]); const [staleItems, setStaleItems] = useState<StaleOrderNotification[]>([]);
const [staleTotal, setStaleTotal] = useState(0); const [staleTotal, setStaleTotal] = useState(0);
const [awaitingItems, setAwaitingItems] = useState<AwaitingPaymentItem[]>([]); const [awaitingItems, setAwaitingItems] = useState<AwaitingPaymentItem[]>([]);
const [awaitingTotal, setAwaitingTotal] = useState(0); const [awaitingTotal, setAwaitingTotal] = useState(0);
const [tab, setTab] = useState<'pending' | 'awaiting'>('pending'); const [inventoryItems, setInventoryItems] = useState<InventoryNotificationItem[]>([]);
const [inventoryTotal, setInventoryTotal] = useState(0);
const [tab, setTab] = useState<'pending' | 'awaiting' | 'inventory'>('pending');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [staleData, awaitingData] = await Promise.all([ const [staleData, awaitingData, inventoryData] = await Promise.all([
ordersApi.staleNotifications(24, 20), ordersApi.staleNotifications(24, 20),
ordersApi.awaitingPaymentNotifications(), ordersApi.awaitingPaymentNotifications(),
inventoryApi.notificationCounts(),
]); ]);
setStaleItems(staleData.items); setStaleItems(staleData.items);
setStaleTotal(staleData.total); setStaleTotal(staleData.total);
setAwaitingItems(awaitingData.items); setAwaitingItems(awaitingData.items);
setAwaitingTotal(awaitingData.total); setAwaitingTotal(awaitingData.total);
setInventoryItems(inventoryData.items);
setInventoryTotal(inventoryData.total);
} catch { /* silent */ } } catch { /* silent */ }
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, []);
@@ -54,7 +67,7 @@ export function OrderNotifications() {
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); }; return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); };
}, [open, load]); }, [open, load]);
const grandTotal = staleTotal + awaitingTotal; const grandTotal = staleTotal + awaitingTotal + inventoryTotal;
const pendingItems = staleItems.filter(i => i.state === 'PENDING'); const pendingItems = staleItems.filter(i => i.state === 'PENDING');
const shippedItems = staleItems.filter(i => i.state === 'SHIPPED'); const shippedItems = staleItems.filter(i => i.state === 'SHIPPED');
@@ -146,6 +159,56 @@ export function OrderNotifications() {
</div> </div>
); );
const TabInventory = () => (
<div>
{loading ? (
<p className="p-6 text-center text-sm text-gray-500">Cargando</p>
) : inventoryItems.length === 0 ? (
<p className="p-6 text-center text-sm text-gray-500">
Sin alertas de inventario
</p>
) : (
<>
<p className="px-4 py-2 text-xs font-semibold text-emerald-600 bg-emerald-50 border-b border-emerald-100">
🏷 {inventoryItems.length} alerta{inventoryItems.length !== 1 ? 's' : ''} operativa{inventoryItems.length !== 1 ? 's' : ''}
</p>
{inventoryItems.map((item) => {
const toneClasses = item.tone === 'red'
? 'bg-red-100 text-red-800'
: 'bg-amber-100 text-amber-800';
const headerClasses = item.tone === 'red'
? 'text-red-600 bg-red-50 border-red-100'
: 'text-amber-600 bg-amber-50 border-amber-100';
const icon = item.id === 'low_stock' ? '⚠️'
: item.id === 'out_of_stock' ? '🚫'
: item.id === 'expired' ? '🗓️'
: item.id === 'expiring_soon' ? '⏳'
: '📉';
return (
<Link key={item.id} href={item.href} onClick={() => setOpen(false)}
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">
{icon} {item.label}
</p>
<p className="mt-0.5 text-xs text-gray-500">{item.href.replace(/^\//, '')}</p>
</div>
<span className={`shrink-0 rounded-full px-2 py-1 text-[11px] font-semibold ${toneClasses}`}>
{item.count}
</span>
</div>
<p className={`mt-2 inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${headerClasses}`}>
{item.id}
</p>
</Link>
);
})}
</>
)}
</div>
);
return ( return (
<div ref={rootRef} className="relative ml-auto"> <div ref={rootRef} className="relative ml-auto">
<button <button
@@ -185,9 +248,15 @@ export function OrderNotifications() {
}`}> }`}>
💳 Esperando pago {awaitingTotal > 0 && <span className="ml-1 rounded-full bg-purple-100 text-purple-700 px-1.5 py-0.5 text-[10px] font-bold">{awaitingTotal}</span>} 💳 Esperando pago {awaitingTotal > 0 && <span className="ml-1 rounded-full bg-purple-100 text-purple-700 px-1.5 py-0.5 text-[10px] font-bold">{awaitingTotal}</span>}
</button> </button>
<button type="button" onClick={() => setTab('inventory')}
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
tab === 'inventory' ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}>
🏷 Inventario {inventoryTotal > 0 && <span className="ml-1 rounded-full bg-emerald-100 text-emerald-700 px-1.5 py-0.5 text-[10px] font-bold">{inventoryTotal}</span>}
</button>
</div> </div>
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{tab === 'pending' ? <TabPending /> : <TabAwaiting />} {tab === 'pending' ? <TabPending /> : tab === 'awaiting' ? <TabAwaiting /> : <TabInventory />}
</div> </div>
{grandTotal > 0 && ( {grandTotal > 0 && (
<Link href="/orders" onClick={() => setOpen(false)} <Link href="/orders" onClick={() => setOpen(false)}

View File

@@ -195,6 +195,11 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
style={{ minHeight: 0 }} style={{ minHeight: 0 }}
> >
<table className="w-full table-fixed"> <table className="w-full table-fixed">
<colgroup>
<col className="w-32 shrink-0" />
<col className="w-16 shrink-0" />
<col className="flex-1 min-w-0" />
</colgroup>
<tbody> <tbody>
{logs.map((entry, i) => ( {logs.map((entry, i) => (
<tr <tr
@@ -220,7 +225,7 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
)} )}
</td> </td>
{/* Message */} {/* Message */}
<td className="px-3 py-0.5 text-gray-300 select-all"> <td className="px-3 py-0.5 text-gray-300 select-all overflow-hidden text-ellipsis">
{/* Extra fields for HTTP request logs */} {/* Extra fields for HTTP request logs */}
{entry.method && entry.url && ( {entry.method && entry.url && (
<span className="mr-2"> <span className="mr-2">

View File

@@ -13,30 +13,39 @@ interface TrendChartProps {
height?: number; height?: number;
} }
export function TrendChart({ data, maxValue, height = 200 }: TrendChartProps) { export function TrendChart({ data, maxValue, height = 120 }: TrendChartProps) {
if (data.length === 0) return null; if (data.length === 0) return null;
const width = 100; // percentage-based SVG const chartWidth = Math.max(420, data.length * 26);
const barWidth = Math.min(3, (width * 0.9) / data.length); const padding = { top: 12, right: 14, bottom: 28, left: 14 };
const gap = Math.max(0.2, (width - barWidth * data.length) / (data.length + 1)); const plotWidth = chartWidth - padding.left - padding.right;
const chartHeight = height - 40; // leave room for labels const chartHeight = Math.max(60, height - padding.top - padding.bottom);
const barWidth = Math.min(16, Math.max(7, (plotWidth / data.length) * 0.62));
const gap = Math.max(4, (plotWidth - barWidth * data.length) / Math.max(data.length + 1, 1));
return ( return (
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto rounded-xl bg-white">
<svg <svg
viewBox={`0 0 ${width} ${height}`} viewBox={`0 0 ${chartWidth} ${height}`}
className="w-full" className="block min-w-full"
style={{ minWidth: `${Math.max(300, data.length * 8)}px` }} style={{ width: '100%', minWidth: `${chartWidth}px`, height: `${height}px` }}
aria-label="Tendencia de ventas" aria-label="Tendencia de ventas"
role="img" role="img"
> >
{/* Y-axis baseline */} {/* Y-axis baseline */}
<line x1="0" y1={chartHeight} x2={width} y2={chartHeight} stroke="#e5e7eb" strokeWidth="0.5" /> <line
x1={padding.left}
y1={padding.top + chartHeight}
x2={chartWidth - padding.right}
y2={padding.top + chartHeight}
stroke="#e5e7eb"
strokeWidth="1"
/>
{data.map((point, i) => { {data.map((point, i) => {
const barH = maxValue > 0 ? (point.value / maxValue) * chartHeight : 0; const barH = maxValue > 0 ? (point.value / maxValue) * chartHeight : 0;
const x = gap + i * (barWidth + gap); const x = padding.left + gap + i * (barWidth + gap);
const y = chartHeight - barH; const y = padding.top + chartHeight - barH;
return ( return (
<g key={i} className="group"> <g key={i} className="group">
@@ -47,16 +56,16 @@ export function TrendChart({ data, maxValue, height = 200 }: TrendChartProps) {
width={barWidth} width={barWidth}
height={barH} height={barH}
fill="#2D6A4F" fill="#2D6A4F"
rx="1" rx="3"
className="transition-all duration-200 hover:fill-[#245a42]" className="transition-all duration-200 hover:fill-[#245a42]"
/> />
{/* Label (show every ~7 bars or if narrow) */}
{data.length <= 31 && ( {data.length <= 31 && (
<text <text
x={x + barWidth / 2} x={x + barWidth / 2}
y={height - 2} y={height - 8}
textAnchor="middle" textAnchor="middle"
fontSize="2.5" fontSize="11"
fontWeight="500"
fill="#9ca3af" fill="#9ca3af"
> >
{point.label.slice(5, 10)} {point.label.slice(5, 10)}

View File

@@ -0,0 +1,67 @@
'use client';
interface PaginationProps {
/** Total number of items across all pages */
total: number;
/** Current offset (0-indexed) */
offset: number;
/** Items per page (default: 20) */
limit?: number;
/** Called when the user navigates to a new offset */
onOffsetChange: (newOffset: number) => void;
/** Optional className for the container */
className?: string;
/** Show when total is 0 (default: false — caller decides) */
hideWhenEmpty?: boolean;
}
/**
* Reusable pagination footer for admin list pages.
* Shows "XY de Z" counter and prev/next buttons.
* Buttons are disabled at the boundaries.
*/
export default function Pagination({
total,
offset,
limit = 20,
onOffsetChange,
className = '',
hideWhenEmpty = true,
}: PaginationProps) {
if (hideWhenEmpty && total === 0) return null;
const from = total === 0 ? 0 : offset + 1;
const to = Math.min(offset + limit, total);
const hasPrev = offset > 0;
const hasNext = offset + limit < total;
return (
<div
className={`flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50 ${className}`}
>
<p className="text-xs text-gray-500">
{total === 0 ? 'Sin resultados' : `Mostrando ${from}${to} de ${total}`}
</p>
<div className="flex gap-1">
<button
type="button"
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
disabled={!hasPrev}
aria-label="Página anterior"
className="px-3 py-1.5 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Anterior
</button>
<button
type="button"
onClick={() => onOffsetChange(offset + limit)}
disabled={!hasNext}
aria-label="Página siguiente"
className="px-3 py-1.5 text-xs border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
>
Siguiente
</button>
</div>
</div>
);
}

View File

@@ -180,7 +180,16 @@ export const customersApi = {
// ── Brands ──────────────────────────────────────────────────────────────────── // ── Brands ────────────────────────────────────────────────────────────────────
export const brandsApi = { export const brandsApi = {
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'), list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Brand[]; total: number }>(
`/api/brands${qs ? `?${qs}` : ''}`,
).then((data) => Array.isArray(data) ? { items: data, total: data.length } : data);
},
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data), create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
update: (id: string, data: unknown) => update: (id: string, data: unknown) =>
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data), api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
@@ -190,7 +199,16 @@ export const brandsApi = {
// ── Categories ──────────────────────────────────────────────────────────────── // ── Categories ────────────────────────────────────────────────────────────────
export const categoriesApi = { export const categoriesApi = {
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'), list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: import('@/types').Category[]; total: number }>(
`/api/categories/tree${qs ? `?${qs}` : ''}`,
).then((data) => Array.isArray(data) ? { items: data, total: data.length } : data);
},
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data), create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
update: (id: string, data: unknown) => update: (id: string, data: unknown) =>
api.patch<import('@/types').Category>(`/api/categories/${id}`, data), api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
@@ -202,10 +220,39 @@ export const categoriesApi = {
// ── Inventory ───────────────────────────────────────────────────────────────── // ── Inventory ─────────────────────────────────────────────────────────────────
export const inventoryApi = { export const inventoryApi = {
listOverview: (params?: {
q?: string;
filter?: 'all' | 'in_stock' | 'low_stock' | 'out_of_stock' | 'expiring' | 'low_margin';
limit?: number;
offset?: number;
}) => {
const sp = new URLSearchParams();
if (params?.q) sp.set('q', params.q);
if (params?.filter) sp.set('filter', params.filter);
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return api.get<{
items: import('@/types').InventoryOverviewItem[];
total: number;
stats: import('@/types').InventoryOverviewStats;
}>(`/api/inventory/admin/overview${qs ? `?${qs}` : ''}`);
},
getAvailability: (variantId: string) => getAvailability: (variantId: string) =>
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`), api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
setStock: (id: string, quantity: number) => setStock: (id: string, quantity: number) =>
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }), api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
notificationCounts: () =>
api.get<{
items: Array<{
id: 'low_stock' | 'out_of_stock' | 'expired' | 'expiring_soon' | 'low_margin';
count: number;
href: string;
label: string;
tone: 'amber' | 'red';
}>;
total: number;
}>('/api/inventory/admin/notifications'),
}; };
// ── Pricing ─────────────────────────────────────────────────────────────────── // ── Pricing ───────────────────────────────────────────────────────────────────
@@ -230,7 +277,16 @@ export const pricingApi = {
// ── Promotions ──────────────────────────────────────────────────────────────── // ── Promotions ────────────────────────────────────────────────────────────────
export const promotionsApi = { export const promotionsApi = {
list: () => api.get<{ items: unknown[] }>('/api/promotions'), list: (params?: { limit?: number; offset?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return api.get<{ items: unknown[]; total: number }>(
`/api/promotions${qs ? `?${qs}` : ''}`,
).then((data) => Array.isArray(data) ? { items: data, total: data.length } : data);
},
create: (data: unknown) => api.post('/api/promotions', data), create: (data: unknown) => api.post('/api/promotions', data),
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data), update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
delete: (code: string) => api.delete(`/api/promotions/${code}`), delete: (code: string) => api.delete(`/api/promotions/${code}`),
@@ -371,6 +427,8 @@ export interface StoreSettings {
footerText: string; footerText: string;
facebookUrl: string; facebookUrl: string;
instagramUrl: string; instagramUrl: string;
twitterUrl: string;
pinterestUrl: string;
aiProvider: string; aiProvider: string;
aiBaseUrl: string; aiBaseUrl: string;
aiModel: string; aiModel: string;
@@ -393,7 +451,86 @@ export interface StoreSettings {
couriers?: string[]; couriers?: string[];
} }
export interface AboutInfo {
productVersion: string;
generatedAt: string;
services: Array<{ id: string; name: string; version: string }>;
server: {
hostname: string;
platform: string;
arch: string;
release: string;
uptimeSeconds: number;
processUptimeSeconds: number;
processStartedAt: string;
nodeVersion: string;
pid: number;
cpuCount: number;
totalMemoryMb: number;
freeMemoryMb: number;
processMemoryMb: { rss: number; heapUsed: number; heapTotal: number };
timezone: string;
};
database: { postgresVersion: string };
}
export const settingsApi = { export const settingsApi = {
get: () => api.get<StoreSettings>('/api/admin/settings'), get: () => api.get<StoreSettings>('/api/admin/settings'),
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data), update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
about: () => api.get<AboutInfo>('/api/admin/about'),
};
// ── Club Admin API ────────────────────────────────────────────────────────────
interface ClubAdminMember {
id: string;
userId: string | null;
userEmail: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
}
interface ClubAdminTransaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
amountCents: number;
balanceDeltaCents: number;
idempotencyKey: string | null;
metadata: Record<string, unknown>;
createdAt: string;
}
interface ClubAdminMembersResponse {
items: ClubAdminMember[];
total: number;
limit: number;
offset: number;
}
export const clubAdminApi = {
listMembers: (opts?: { q?: string; status?: string; limit?: number; offset?: number }) => {
const params = new URLSearchParams();
if (opts?.q) params.set('q', opts.q);
if (opts?.status) params.set('status', opts.status);
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
const qs = params.toString();
return api.get<ClubAdminMembersResponse>(`/api/admin/club/members${qs ? `?${qs}` : ''}`);
},
getMember: (memberId: string) =>
api.get<{ member: ClubAdminMember }>(`/api/admin/club/members/${memberId}`),
getMemberMovements: (memberId: string, limit = 50) => {
const params = new URLSearchParams({ limit: String(limit) });
return api.get<{ member: ClubAdminMember; items: ClubAdminTransaction[] }>(
`/api/admin/club/members/${memberId}/movements?${params}`,
);
},
}; };

View File

@@ -45,6 +45,7 @@ export const NAV_ITEMS: NavItem[] = [
{ href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' }, { href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' }, { href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' }, { href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/club', label: 'Club', icon: '🎫', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' }, { href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' }, { href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' }, { href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },

View File

@@ -72,6 +72,25 @@ export interface StockItem {
updatedAt: string; updatedAt: string;
} }
export interface InventoryOverviewItem {
productId: string;
productName: string;
productSlug: string;
productActive: boolean;
expirationDate: string | null;
variant: ProductVariant;
stock: StockAvailability;
price: VariantPrice | null;
marginPercent: number | null;
}
export interface InventoryOverviewStats {
total: number;
inStock: number;
lowStock: number;
outOfStock: number;
}
// ── Orders ──────────────────────────────────────────────────────────────────── // ── Orders ────────────────────────────────────────────────────────────────────
export type OrderState = export type OrderState =
@@ -81,6 +100,7 @@ export type OrderState =
| 'PROCESSING' | 'PROCESSING'
| 'SHIPPED' | 'SHIPPED'
| 'DELIVERED' | 'DELIVERED'
| 'COMPLETED'
| 'CANCELLED' | 'CANCELLED'
| 'REFUNDED' | 'REFUNDED'
| 'PARTIALLY_REFUNDED'; | 'PARTIALLY_REFUNDED';

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ interface Config {
terminal: { terminal: {
id: string; id: string;
name: string; name: string;
settings?: { lineDiscountsEnabled?: boolean } & Record<string, unknown>; settings?: { lineDiscountsEnabled?: boolean; selfpayMode?: boolean } & Record<string, unknown>;
}; };
store: { id: string; name: string }; store: { id: string; name: string };
paymentMethods: PaymentMethod[]; paymentMethods: PaymentMethod[];
@@ -140,6 +140,11 @@ export default function RegisterPage() {
// POS-FIX-4: toast notification when product added to cart // POS-FIX-4: toast notification when product added to cart
const [addedToast, setAddedToast] = useState<string | null>(null); const [addedToast, setAddedToast] = useState<string | null>(null);
// POS-SELFPAY-FLOW: optional email for receipt before payment
const [showSelfpayEmail, setShowSelfpayEmail] = useState(false);
const [selfpayEmail, setSelfpayEmail] = useState('');
const [selfpayPaymentReady, setSelfpayPaymentReady] = useState(false);
const [showSelfpayPaymentMethods, setShowSelfpayPaymentMethods] = useState(false);
let toastTimer: ReturnType<typeof setTimeout> | undefined; let toastTimer: ReturnType<typeof setTimeout> | undefined;
const showAddedToast = (name: string) => { const showAddedToast = (name: string) => {
clearTimeout(toastTimer); clearTimeout(toastTimer);
@@ -439,7 +444,8 @@ export default function RegisterPage() {
}, [cart]); }, [cart]);
const paidCents = payments.reduce((sum, payment) => sum + payment.amountCents, 0); const paidCents = payments.reduce((sum, payment) => sum + payment.amountCents, 0);
const remainingCents = Math.max(0, totals.total - paidCents); const remainingCents = Math.max(0, totals.total - paidCents);
const lineDiscountsEnabled = config?.terminal.settings?.lineDiscountsEnabled !== false; const isSelfpayMode = config?.terminal.settings?.selfpayMode === true;
const lineDiscountsEnabled = !isSelfpayMode && config?.terminal.settings?.lineDiscountsEnabled !== false;
const applyDiscount = (lineId: string, discountCents: number) => { const applyDiscount = (lineId: string, discountCents: number) => {
setCart((current) => setCart((current) =>
@@ -474,25 +480,28 @@ export default function RegisterPage() {
setProcessing(true); setProcessing(true);
setError(''); setError('');
try { try {
// TPV-FIXES: items without variantId (recovered sales) must be sent as free items
const saleItems = cart.map((item) => {
if (item.kind === 'free' || !item.variantId) {
return {
kind: 'free' as const,
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
};
}
return {
kind: 'stock' as const,
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
};
});
const result = await posApi.createSale<PosSaleResponse>({ const result = await posApi.createSale<PosSaleResponse>({
idempotencyKey: generateIdempotencyKey(), idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id, cashSessionId: config.session.id,
terminalId: config.terminal.id, terminalId: config.terminal.id,
items: cart.map((item) => items: saleItems,
item.kind === 'free'
? {
kind: 'free',
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
}
: {
kind: 'stock',
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
},
),
payments: payments.map((payment) => ({ payments: payments.map((payment) => ({
methodCode: payment.methodCode, methodCode: payment.methodCode,
amountCents: payment.amountCents, amountCents: payment.amountCents,
@@ -502,6 +511,72 @@ export default function RegisterPage() {
}); });
setReceipt(result.receipt); setReceipt(result.receipt);
setRestPaymentFor(null); setRestPaymentFor(null);
// POS-SELFPAY-FLOW: send receipt by email if provided
if (isSelfpayMode && selfpayEmail.trim()) {
void posApi.emailReceipt(result.receipt.orderId, selfpayEmail.trim()).catch(() => {
// Non-blocking: email failure shouldn't interrupt the sale
});
}
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta');
} finally {
setProcessing(false);
}
};
// POS-SELFPAY-FLOW: confirm sale with explicit payment list (avoids stale state in selfpay modal)
const confirmSaleWith = async (allocations: PaymentAllocation[]) => {
if (!config?.session) {
setError('No hay sesión abierta');
return;
}
if (cart.length === 0) {
setError('Carrito vacío');
return;
}
const totalAllocated = allocations.reduce((sum, a) => sum + a.amountCents, 0);
if (totalAllocated > totals.total) {
setError('Los pagos asignados superan el total');
return;
}
setPayments(allocations);
setProcessing(true);
setError('');
try {
const saleItems = cart.map((item) => {
if (item.kind === 'free' || !item.variantId) {
return {
kind: 'free' as const,
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
};
}
return {
kind: 'stock' as const,
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
};
});
const result = await posApi.createSale<PosSaleResponse>({
idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id,
terminalId: config.terminal.id,
items: saleItems,
payments: allocations.map((payment) => ({
methodCode: payment.methodCode,
amountCents: payment.amountCents,
...(payment.kind === 'cash' ? { tenderedCents: payment.tenderedCents } : {}),
})),
...(customer ? { customerId: customer.id } : {}),
});
setReceipt(result.receipt);
setRestPaymentFor(null);
if (isSelfpayMode && selfpayEmail.trim()) {
void posApi.emailReceipt(result.receipt.orderId, selfpayEmail.trim()).catch(() => {});
}
void loadPendingSales(); void loadPendingSales();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta'); setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta');
@@ -536,25 +611,28 @@ export default function RegisterPage() {
setProcessing(true); setProcessing(true);
setError(''); setError('');
try { try {
// TPV-FIXES: items without variantId (recovered sales) must be sent as free items
const saleItems = cart.map((item) => {
if (item.kind === 'free' || !item.variantId) {
return {
kind: 'free' as const,
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
};
}
return {
kind: 'stock' as const,
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
};
});
const result = await posApi.createSale<PosSaleResponse>({ const result = await posApi.createSale<PosSaleResponse>({
idempotencyKey: generateIdempotencyKey(), idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id, cashSessionId: config.session.id,
terminalId: config.terminal.id, terminalId: config.terminal.id,
items: cart.map((item) => items: saleItems,
item.kind === 'free'
? {
kind: 'free',
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
}
: {
kind: 'stock',
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
},
),
payments: [], payments: [],
...(customer ? { customerId: customer.id } : {}), ...(customer ? { customerId: customer.id } : {}),
...(name ? { posLabel: name } : {}), ...(name ? { posLabel: name } : {}),
@@ -664,8 +742,8 @@ export default function RegisterPage() {
sku: item.sku, sku: item.sku,
name: item.name, name: item.name,
ean: null, ean: null,
unitPriceCents: item.unitPriceCents, unitPriceCents: Math.max(item.unitPriceCents - item.discountCents, 0),
discountCents: item.discountCents, discountCents: 0,
taxCents: 0, taxCents: 0,
quantity: item.quantity - item.returnedQuantity, quantity: item.quantity - item.returnedQuantity,
stock: null, stock: null,
@@ -686,27 +764,32 @@ export default function RegisterPage() {
setRecoveringSaleId(mergePendingSale.id); setRecoveringSaleId(mergePendingSale.id);
setError(''); setError('');
try { try {
// Park current cart first // TPV-FIXES: items without variantId (recovered sales) must be sent as free items
const parkItems = cart.map((item) => {
if (item.kind === 'free' || !item.variantId) {
// Free item or recovered item without variantId
return {
kind: 'free' as const,
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
};
}
return {
kind: 'stock' as const,
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
};
});
// Park current cart first (with customer if available)
await posApi.createSale<PosSaleResponse>({ await posApi.createSale<PosSaleResponse>({
idempotencyKey: generateIdempotencyKey(), idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id, cashSessionId: config.session.id,
terminalId: config.terminal.id, terminalId: config.terminal.id,
items: cart.map((item) => items: parkItems,
item.kind === 'free'
? {
kind: 'free',
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
}
: {
kind: 'stock',
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
},
),
payments: [], payments: [],
...(customer ? { customerId: customer.id } : {}),
}); });
// Then recover the selected sale // Then recover the selected sale
await doRecoverSale(mergePendingSale); await doRecoverSale(mergePendingSale);
@@ -732,8 +815,8 @@ export default function RegisterPage() {
sku: item.sku, sku: item.sku,
name: item.name, name: item.name,
ean: null, ean: null,
unitPriceCents: item.unitPriceCents, unitPriceCents: Math.max(item.unitPriceCents - item.discountCents, 0),
discountCents: item.discountCents, discountCents: 0,
taxCents: 0, taxCents: 0,
quantity: item.quantity - item.returnedQuantity, quantity: item.quantity - item.returnedQuantity,
stock: null, stock: null,
@@ -768,6 +851,9 @@ export default function RegisterPage() {
setError(''); setError('');
setSearch(''); setSearch('');
setShowClearConfirm(false); setShowClearConfirm(false);
setSelfpayPaymentReady(false);
setSelfpayEmail('');
setShowSelfpayPaymentMethods(false);
}; };
const searchCustomers = async (query: string) => { const searchCustomers = async (query: string) => {
@@ -910,7 +996,7 @@ export default function RegisterPage() {
return ( return (
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}> <div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
{showSidebar && ( {!isSelfpayMode && showSidebar && (
<aside <aside
className="hidden w-[380px] shrink-0 flex-col border-r bg-gray-50 p-3 lg:flex" className="hidden w-[380px] shrink-0 flex-col border-r bg-gray-50 p-3 lg:flex"
aria-label="Pedidos" aria-label="Pedidos"
@@ -1189,6 +1275,7 @@ export default function RegisterPage() {
<div className="mb-3"> <div className="mb-3">
<div className="mb-2 flex items-center gap-3"> <div className="mb-2 flex items-center gap-3">
{/* Panel toggle button - LEFT */} {/* Panel toggle button - LEFT */}
{!isSelfpayMode && (
<button <button
type="button" type="button"
onClick={() => setShowSidebar((v) => !v)} onClick={() => setShowSidebar((v) => !v)}
@@ -1200,6 +1287,7 @@ export default function RegisterPage() {
</svg> </svg>
{showSidebar ? 'Ocultar' : 'Panel'} {showSidebar ? 'Ocultar' : 'Panel'}
</button> </button>
)}
{/* Store name */} {/* Store name */}
<span className="text-sm font-bold text-[#2D6A4F]">{config.store.name}</span> <span className="text-sm font-bold text-[#2D6A4F]">{config.store.name}</span>
@@ -1223,7 +1311,7 @@ export default function RegisterPage() {
{currentTime.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} {currentTime.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span> </span>
{!config?.terminal?.settings?.selfpayMode && ( {!isSelfpayMode && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1577,7 +1665,7 @@ export default function RegisterPage() {
<span>Subtotal</span> <span>Subtotal</span>
<span>{formatPrice(totals.subtotal)}</span> <span>{formatPrice(totals.subtotal)}</span>
</div> </div>
{totals.discount > 0 && ( {!isSelfpayMode && totals.discount > 0 && (
<div className="flex justify-between text-red-600"> <div className="flex justify-between text-red-600">
<span>Descuentos</span> <span>Descuentos</span>
<span>{formatPrice(totals.discount)}</span> <span>{formatPrice(totals.discount)}</span>
@@ -1631,6 +1719,8 @@ export default function RegisterPage() {
{error} {error}
</p> </p>
)} )}
{/* POS-SELFPAY-FLOW: in cashier mode show inline payment methods; in selfpay show modal */}
{!isSelfpayMode && (
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
{config.paymentMethods.map((method) => ( {config.paymentMethods.map((method) => (
<button <button
@@ -1644,7 +1734,11 @@ export default function RegisterPage() {
</button> </button>
))} ))}
</div> </div>
)}
{/* POS-SELFPAY-FLOW: confirm button no longer needed — direct flow */}
{/* (Removed: payment directly triggers confirmSale from the modal) */}
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
{!isSelfpayMode && (
<button <button
type="button" type="button"
onClick={() => void parkSale()} onClick={() => void parkSale()}
@@ -1653,19 +1747,24 @@ export default function RegisterPage() {
> >
{processing ? 'Guardando…' : 'Guardar pendiente'} {processing ? 'Guardando…' : 'Guardar pendiente'}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={() => void confirmSale()} onClick={() =>
isSelfpayMode
? setShowSelfpayEmail(true)
: void confirmSale()
}
disabled={ disabled={
processing || processing ||
cart.length === 0 || cart.length === 0 ||
payments.length === 0 || (!isSelfpayMode && (payments.length === 0 || paidCents <= 0 || paidCents > totals.total))
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'} {processing ? 'Confirmando…' : isSelfpayMode ? '💳 Pagar' : 'Cobrar e imprimir'}
</button> </button>
</div> </div>
<button <button
@@ -1674,11 +1773,11 @@ export default function RegisterPage() {
disabled={processing || cart.length === 0} 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" 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> </button>
</aside> </aside>
{showDiscountPanel && selectedItem && ( {!isSelfpayMode && showDiscountPanel && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<DiscountPanel <DiscountPanel
unitPriceCents={selectedItem.unitPriceCents} unitPriceCents={selectedItem.unitPriceCents}
@@ -1695,6 +1794,144 @@ export default function RegisterPage() {
</div> </div>
)} )}
{showFreeItem && <FreeItemModal onAdd={addFreeItem} onClose={() => setShowFreeItem(false)} />} {showFreeItem && <FreeItemModal onAdd={addFreeItem} onClose={() => setShowFreeItem(false)} />}
{/* POS-SELFPAY-FLOW: optional email modal before payment */}
{showSelfpayEmail && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="selfpay-email-title"
>
<div className="w-full max-w-sm space-y-5 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-[#2D6A4F]">
Total {formatPrice(totals.total)}
</p>
<h2 id="selfpay-email-title" className="text-2xl font-bold text-gray-900">
Recibir ticket por email
</h2>
<p className="mt-1 text-sm text-gray-500">
¿Quieres recibir el ticket de compra?
</p>
</div>
<button
type="button"
onClick={() => {
setSelfpayEmail('');
setShowSelfpayEmail(false);
}}
className="min-h-12 min-w-12 rounded-xl bg-gray-100 text-xl"
aria-label="Cerrar"
>
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
setShowSelfpayEmail(false);
setSelfpayPaymentReady(true);
setShowSelfpayPaymentMethods(true);
}}
className="space-y-4"
>
<div>
<label htmlFor="selfpay-email" className="mb-1 block text-sm font-medium text-gray-700">
Email (opcional)
</label>
<input
id="selfpay-email"
type="email"
value={selfpayEmail}
onChange={(e) => setSelfpayEmail(e.target.value)}
placeholder="tu@email.com"
className="w-full rounded-xl border border-gray-300 px-4 py-3 text-sm outline-none focus:border-[#2D6A4F]"
/>
</div>
<button
type="submit"
className="w-full rounded-xl bg-[#2D6A4F] py-3 font-bold text-white"
>
Continuar al pago
</button>
<button
type="button"
onClick={() => {
setSelfpayEmail('');
setShowSelfpayEmail(false);
setSelfpayPaymentReady(true);
setShowSelfpayPaymentMethods(true);
}}
className="w-full rounded-xl border border-gray-300 py-2 text-sm font-medium text-gray-500"
>
Omitir seguir sin email
</button>
</form>
</div>
</div>
)}
{/* POS-SELFPAY-FLOW: payment methods modal in selfpay mode */}
{showSelfpayPaymentMethods && isSelfpayMode && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="selfpay-payment-title"
>
<div className="w-full max-w-md space-y-4 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-[#2D6A4F]">
Total {formatPrice(totals.total)}
</p>
<h2 id="selfpay-payment-title" className="text-2xl font-bold text-gray-900">
¿Cómo quieres pagar?
</h2>
</div>
<button
type="button"
onClick={() => setShowSelfpayPaymentMethods(false)}
className="min-h-12 min-w-12 rounded-xl bg-gray-100 text-xl"
aria-label="Cerrar"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{config.paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => {
// POS-SELFPAY-FLOW: no partial payments — directly add full payment and confirm.
// Clamp to totals.total to avoid 1-cent rounding mismatches with the backend.
setShowSelfpayPaymentMethods(false);
const amountCents = Math.min(remainingCents, totals.total);
const allocation: PaymentAllocation = {
id: generateIdempotencyKey(),
methodCode: method.code,
methodLabel: method.label,
kind: method.kind,
amountCents,
...(method.kind === 'cash' ? { tenderedCents: amountCents } : {}),
changeCents: 0,
};
void confirmSaleWith([allocation]);
}}
disabled={remainingCents === 0}
className={`min-h-16 rounded-xl px-2 font-bold text-white disabled:opacity-40 ${method.kind === 'cash' ? 'bg-green-600' : method.kind === 'card' ? 'bg-blue-600' : 'bg-slate-700'}`}
>
{method.kind === 'cash' ? '💵' : method.kind === 'card' ? '💳' : '◉'} {method.label}
</button>
))}
</div>
</div>
</div>
)}
{paymentMethod && ( {paymentMethod && (
<PaymentModal <PaymentModal
method={paymentMethod} method={paymentMethod}
@@ -2007,8 +2244,13 @@ export default function RegisterPage() {
{receipt && ( {receipt && (
<ReceiptModal <ReceiptModal
receipt={receipt} receipt={receipt}
initialEmail={customer?.email ?? receipt.customerEmail ?? ''} initialEmail={
isSelfpayMode
? selfpayEmail || undefined
: customer?.email ?? receipt.customerEmail ?? ''
}
onDelivered={resetCashier} onDelivered={resetCashier}
showActions={!isSelfpayMode}
onReturn={ onReturn={
config?.session && config?.terminal config?.session && config?.terminal
? (orderId, latest) => { ? (orderId, latest) => {

View File

@@ -1,9 +1,23 @@
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Mercado de Vida — TPV', title: 'Mercado de Vida — TPV',
description: 'Terminal punto de venta', description: 'Terminal punto de venta',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'TPV MdV',
},
manifest: '/manifest.json',
};
export const viewport: Viewport = {
themeColor: '#70AD47',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,24 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Mercado de Vida — TPV',
short_name: 'TPV MdV',
description: 'Terminal punto de venta Mercado de Vida',
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'landscape',
background_color: '#111827',
theme_color: '#70AD47',
categories: ['business', 'shopping'],
icons: [
{
src: '/images/logo-main.png',
sizes: 'any',
type: 'image/png',
purpose: 'any',
},
],
};
}

View File

@@ -13,6 +13,25 @@ interface ReceiptModalProps {
showActions?: boolean; // false when used just for printing showActions?: boolean; // false when used just for printing
} }
function vatRateLabel(rate: 'general' | 'reduced' | 'super-reduced'): string {
switch (rate) {
case 'general':
return '21% (general)';
case 'reduced':
return '10% (reducido)';
case 'super-reduced':
return '4% (super-reducido)';
}
}
function taxBaseCents(receipt: PosReceipt): number {
return (
receipt.taxBreakdown.generalBaseCents +
receipt.taxBreakdown.reducedBaseCents +
receipt.taxBreakdown.superReducedBaseCents
);
}
export default function ReceiptModal({ export default function ReceiptModal({
receipt, receipt,
initialEmail = '', initialEmail = '',
@@ -54,20 +73,28 @@ export default function ReceiptModal({
return ( return (
<div <div
className="fixed inset-0 z-[60] overflow-y-auto bg-black/50 p-4 print:static print:bg-white print:p-0" className="fixed inset-0 z-[60] overflow-hidden bg-black/50 p-4 print:static print:bg-white print:p-0"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="receipt-title" aria-labelledby="receipt-title"
> >
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl print:max-w-none print:rounded-none print:p-0 print:shadow-none"> <div className="relative mx-auto flex h-full max-h-[calc(100vh-2rem)] w-full max-w-xl flex-col rounded-2xl bg-white p-6 shadow-2xl print:max-h-none print:max-w-none print:rounded-none print:p-0 print:shadow-none">
{/* Ticket content - only this gets printed */} <button
<div ref={articleRef} className="ticket-print-area"> type="button"
<article className="space-y-4 text-sm text-gray-900"> onClick={onDelivered}
aria-label="Cerrar"
className="no-print absolute right-4 top-4 z-10 text-2xl text-gray-400 hover:text-gray-700"
>
</button>
{/* Ticket content - scrollable area, only this gets printed */}
<div ref={articleRef} className="ticket-print-area flex-1 overflow-y-auto print:overflow-visible">
<article className="space-y-4 bg-white text-sm text-gray-900 print:bg-white">
<header className="border-b border-dashed border-gray-400 pb-4 text-center"> <header className="border-b border-dashed border-gray-400 pb-4 text-center">
{/* Logo */} {/* Logo */}
<div className="mb-3 flex justify-center"> <div className="mb-3 flex justify-center">
<img <img
src="/images/logo-main.png" src={receipt.logoUrl ?? '/images/logo-main.png'}
alt="Logo" alt="Logo"
className="h-16 object-contain print:h-12" className="h-16 object-contain print:h-12"
/> />
@@ -94,7 +121,7 @@ export default function ReceiptModal({
<strong>Terminal:</strong> {receipt.terminal.name} <strong>Terminal:</strong> {receipt.terminal.name}
</p> </p>
<p className="text-right"> <p className="text-right">
<strong>Cajero:</strong> {receipt.cashier} <strong>Cajero:</strong> {receipt.cashier?.split('@')[0] ?? '—'}
</p> </p>
</div> </div>
@@ -114,6 +141,11 @@ export default function ReceiptModal({
<span className="block text-xs text-gray-500"> <span className="block text-xs text-gray-500">
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''} {formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
</span> </span>
{!item.freeItem && item.vatRate && (
<span className="block text-xs text-gray-400">
IVA {vatRateLabel(item.vatRate)}
</span>
)}
{item.discountCents > 0 && ( {item.discountCents > 0 && (
<span className="block text-xs text-red-600"> <span className="block text-xs text-red-600">
Dto. {formatPrice(item.discountCents)} Dto. {formatPrice(item.discountCents)}
@@ -138,8 +170,30 @@ export default function ReceiptModal({
<span>{formatPrice(receipt.discountCents)}</span> <span>{formatPrice(receipt.discountCents)}</span>
</p> </p>
)} )}
<p className="flex justify-between"> <p className="flex justify-between border-t border-gray-300 pt-1 font-semibold">
<span>IVA</span> <span>Base imponible</span>
<span>{formatPrice(taxBaseCents(receipt))}</span>
</p>
{receipt.taxBreakdown.generalTaxCents > 0 && (
<p className="flex justify-between text-xs text-gray-600">
<span>· IVA 21% (general)</span>
<span>{formatPrice(receipt.taxBreakdown.generalTaxCents)}</span>
</p>
)}
{receipt.taxBreakdown.reducedTaxCents > 0 && (
<p className="flex justify-between text-xs text-gray-600">
<span>· IVA 10% (reducido)</span>
<span>{formatPrice(receipt.taxBreakdown.reducedTaxCents)}</span>
</p>
)}
{receipt.taxBreakdown.superReducedTaxCents > 0 && (
<p className="flex justify-between text-xs text-gray-600">
<span>· IVA 4% (super-reducido)</span>
<span>{formatPrice(receipt.taxBreakdown.superReducedTaxCents)}</span>
</p>
)}
<p className="flex justify-between font-semibold">
<span>Total IVA</span>
<span>{formatPrice(receipt.taxCents)}</span> <span>{formatPrice(receipt.taxCents)}</span>
</p> </p>
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold"> <p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
@@ -179,20 +233,19 @@ export default function ReceiptModal({
</article> </article>
</div> </div>
{/* Actions - hidden when printing */} {/* Actions - always visible (sticky at bottom) - hidden when printing */}
{showActions && ( <div className="no-print mt-4 flex-shrink-0 space-y-3 border-t bg-white pt-4">
<div className="no-print mt-6 space-y-3 border-t pt-5">
<p className="text-center text-sm text-gray-600"> <p className="text-center text-sm text-gray-600">
Imprime o envía el ticket para preparar la siguiente venta. {showActions ? 'Imprime o envía el ticket para preparar la siguiente venta.' : 'Pulsa Imprimir para terminar.'}
</p> </p>
<button <button
type="button" type="button"
onClick={printReceipt} onClick={printReceipt}
className="no-print min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white" className="no-print min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
> >
🖨 Imprimir ticket 🖨 Imprimir y cerrar
</button> </button>
{onReturn && !receipt.isReturn && ( {showActions && onReturn && !receipt.isReturn && (
<button <button
type="button" type="button"
onClick={() => onReturn(receipt.orderId, receipt)} onClick={() => onReturn(receipt.orderId, receipt)}
@@ -201,6 +254,7 @@ export default function ReceiptModal({
Devolver artículos Devolver artículos
</button> </button>
)} )}
{showActions && (
<form onSubmit={emailReceipt} className="flex gap-2 no-print"> <form onSubmit={emailReceipt} className="flex gap-2 no-print">
<label className="sr-only" htmlFor="receipt-email"> <label className="sr-only" htmlFor="receipt-email">
Email del ticket Email del ticket
@@ -221,18 +275,25 @@ export default function ReceiptModal({
{sending ? 'Enviando…' : '✉ Enviar'} {sending ? 'Enviando…' : '✉ Enviar'}
</button> </button>
</form> </form>
)}
{error && ( {error && (
<p className="no-print text-sm font-medium text-red-600" aria-live="polite"> <p className="no-print text-sm font-medium text-red-600" aria-live="polite">
{error} {error}
</p> </p>
)} )}
</div> </div>
)}
</div> </div>
{/* Print-only styles */} {/* Print-only styles */}
<style>{` <style>{`
@media print { @media print {
@page {
background: #ffffff;
}
html,
body {
background: #ffffff !important;
}
body * { body * {
visibility: hidden; visibility: hidden;
} }
@@ -245,6 +306,10 @@ export default function ReceiptModal({
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100%;
background: #ffffff !important;
color: #111827;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
} }
} }
`}</style> `}</style>

View File

@@ -12,6 +12,15 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
...(init?.headers ?? {}), ...(init?.headers ?? {}),
}, },
}); });
if (res.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw Object.assign(new Error('Sesión caducada, identifícate de nuevo'), {
status: 401,
code: 'UNAUTHORIZED',
});
}
if (!res.ok) { if (!res.ok) {
const err = (await res.json().catch(() => ({ message: res.statusText }))) as { const err = (await res.json().catch(() => ({ message: res.statusText }))) as {
message?: string; message?: string;
@@ -22,6 +31,15 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
code: err.error?.code, code: err.error?.code,
}); });
} }
// Defensive: if a proxy/misconfiguration ever returns HTML on a successful
// response, surface a readable message instead of a JSON parse crash.
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw Object.assign(new Error('Respuesta inesperada del servidor (sesión o proxy)'), {
status: res.status,
code: 'INVALID_RESPONSE',
});
}
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }

View File

@@ -1,13 +1,20 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
const PUBLIC_PATHS = ['/login', '/api/auth', '/api/backoffice/auth']; const PUBLIC_PATHS = [
'/login',
'/api/auth',
'/api/backoffice/auth',
'/manifest.json',
'/manifest.webmanifest',
'/favicon.ico',
];
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Allow public paths // Allow public paths (including PWA manifest, which Authelia was intercepting)
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + '/'))) {
return NextResponse.next(); return NextResponse.next();
} }
@@ -20,6 +27,15 @@ export function proxy(request: NextRequest) {
cookie.includes('session_token='); cookie.includes('session_token=');
if (!hasSession) { if (!hasSession) {
// API callers expect JSON. Redirecting them to /login makes fetch() follow
// the redirect and receive the HTML login page, which surfaces as the
// cryptic "Unexpected token '<'" parse error in the terminal UI.
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: { statusCode: 401, code: 'UNAUTHORIZED', message: 'Authentication required' } },
{ status: 401 },
);
}
return NextResponse.redirect(new URL('/login', request.url)); return NextResponse.redirect(new URL('/login', request.url));
} }

View File

@@ -25,6 +25,17 @@ export interface ReceiptItem {
taxCents: number; taxCents: number;
totalCents: number; totalCents: number;
freeItem: boolean; freeItem: boolean;
/** Type of VAT applied to this line. null when unknown (free items, legacy). */
vatRate: 'general' | 'reduced' | 'super-reduced' | null;
}
export interface ReceiptTaxBreakdown {
generalBaseCents: number;
generalTaxCents: number;
reducedBaseCents: number;
reducedTaxCents: number;
superReducedBaseCents: number;
superReducedTaxCents: number;
} }
export interface ReceiptPayment { export interface ReceiptPayment {
@@ -47,6 +58,7 @@ export interface PosReceipt {
email: string | null; email: string | null;
phone: string | null; phone: string | null;
}; };
logoUrl: string | null;
terminal: { id: string; name: string }; terminal: { id: string; name: string };
cashier: string; cashier: string;
sessionId: string; sessionId: string;
@@ -56,6 +68,7 @@ export interface PosReceipt {
discountCents: number; discountCents: number;
taxCents: number; taxCents: number;
totalCents: number; totalCents: number;
taxBreakdown: ReceiptTaxBreakdown;
payments: ReceiptPayment[]; payments: ReceiptPayment[];
changeCents: number; changeCents: number;
header: string | null; header: string | null;
@@ -74,6 +87,8 @@ export interface PosSaleResponse {
outstandingCents: number; outstandingCents: number;
changeCents: number; changeCents: number;
receipt: PosReceipt; receipt: PosReceipt;
clubEarnedCents: number;
clubMemberCode?: string;
} }
export interface PosPendingSale { export interface PosPendingSale {

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.1.0", "version": "0.2.10",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"next": "16.3.1", "next": "16.3.1",
"qrcode": "^1.5.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"sharp": "^0.35.3" "sharp": "^0.35.3"
@@ -17,6 +18,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/qrcode": "^1.5.5",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -100,6 +100,17 @@ function parseItems(raw: RawItem[] | undefined): {
}); });
} }
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
const text = await response.text();
if (!text) return fallback;
try {
const data = JSON.parse(text) as { error?: { message?: string }; message?: string };
return data.error?.message ?? data.message ?? text;
} catch {
return text;
}
}
async function syncCart( async function syncCart(
cookies: string, cookies: string,
items: { productId: string; variantId: string; quantity: number }[], items: { productId: string; variantId: string; quantity: number }[],
@@ -125,7 +136,9 @@ async function syncCart(
} }
const nextByVariant = new Map(items.map((item) => [item.variantId, item])); const nextByVariant = new Map(items.map((item) => [item.variantId, item]));
const currentByVariant = new Map(serverItems.map((item) => [item.variantId, item]));
const operations: Promise<Response>[] = []; const operations: Promise<Response>[] = [];
for (const item of serverItems) { for (const item of serverItems) {
if (!nextByVariant.has(item.variantId)) { if (!nextByVariant.has(item.variantId)) {
operations.push( operations.push(
@@ -136,7 +149,10 @@ async function syncCart(
); );
} }
} }
for (const item of items) { for (const item of items) {
const existing = currentByVariant.get(item.variantId);
if (!existing) {
operations.push( operations.push(
fetch(`${API}/cart/items`, { fetch(`${API}/cart/items`, {
method: 'POST', method: 'POST',
@@ -144,12 +160,24 @@ async function syncCart(
body: JSON.stringify(item), body: JSON.stringify(item),
}), }),
); );
continue;
} }
if (existing.quantity !== item.quantity) {
operations.push(
fetch(`${API}/cart/items/${item.variantId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body: JSON.stringify({ quantity: item.quantity }),
}),
);
}
}
const results = await Promise.all(operations); const results = await Promise.all(operations);
for (const response of results) { for (const response of results) {
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
const message = await response.text(); const message = await readErrorMessage(response, 'Error al sincronizar el carrito');
return { ok: false, status: response.status, message: message || 'Error al sincronizar el carrito' }; return { ok: false, status: response.status, message };
} }
} }
return { ok: true }; return { ok: true };
@@ -241,5 +269,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json(UNAUTHORIZED, { status: 401 }); return NextResponse.json(UNAUTHORIZED, { status: 401 });
} }
if (!backendRes.ok) {
const message = await readErrorMessage(backendRes, 'Error al procesar el pedido');
return NextResponse.json(
{ error: { code: 'CHECKOUT_FAILED', message } },
{ status: backendRes.status },
);
}
return NextResponse.json(payload, { status: backendRes.status }); return NextResponse.json(payload, { status: backendRes.status });
} }

View File

@@ -0,0 +1,44 @@
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) {
return handle(request);
}
export async function POST(request: NextRequest) {
return handle(request);
}
export async function PATCH(request: NextRequest) {
return handle(request);
}
async function handle(request: NextRequest): Promise<NextResponse> {
const path = request.nextUrl.pathname.replace('/api/', '');
const url = `${API}/${path}${request.nextUrl.search}`;
const headers: Record<string, string> = { accept: 'application/json' };
const cookie = request.headers.get('cookie');
if (cookie) headers.cookie = cookie;
const contentType = request.headers.get('content-type');
if (contentType) headers['content-type'] = contentType;
const init: RequestInit = { method: request.method, headers };
if (request.method !== 'GET' && request.method !== 'HEAD') {
init.body = await request.text();
}
try {
const response = await fetch(url, init);
const body = await response.json().catch(() => ({}));
const next = NextResponse.json(body, { status: response.status });
const setCookie = response.headers.get('set-cookie');
if (setCookie) next.headers.set('set-cookie', setCookie);
return next;
} catch (error) {
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: error instanceof Error ? error.message : 'Club proxy failed' } },
{ status: 502 },
);
}
}

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
export default function LoginPage() { function LoginForm() {
const { login } = useAuth(); const { login } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const returnTo = searchParams.get('returnTo') ?? '/';
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -19,7 +21,7 @@ export default function LoginPage() {
const result = await login(email, password); const result = await login(email, password);
setLoading(false); setLoading(false);
if (result.ok) { if (result.ok) {
router.push('/'); router.push(returnTo);
} else { } else {
setError(result.error || 'Credenciales inválidas'); setError(result.error || 'Credenciales inválidas');
} }
@@ -82,3 +84,11 @@ export default function LoginPage() {
</div> </div>
); );
} }
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
export default function RegisterPage() { function RegisterForm() {
const { register } = useAuth(); const { register } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const returnTo = searchParams.get('returnTo') ?? '/';
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState(''); const [confirm, setConfirm] = useState('');
@@ -25,7 +27,7 @@ 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) {
setDone(true); router.push(returnTo);
} else { } else {
setError(result.error || 'Error al crear cuenta'); setError(result.error || 'Error al crear cuenta');
} }
@@ -120,3 +122,11 @@ export default function RegisterPage() {
</div> </div>
); );
} }
export default function RegisterPage() {
return (
<Suspense>
<RegisterForm />
</Suspense>
);
}

View File

@@ -0,0 +1,5 @@
import { ClubExperience } from '@/components/club/ClubExperience';
export default function ClubCardPage() {
return <ClubExperience mode="card" />;
}

View File

@@ -0,0 +1,5 @@
import { ClubExperience } from '@/components/club/ClubExperience';
export default function ClubJoinPage() {
return <ClubExperience mode="join" />;
}

View File

@@ -0,0 +1,5 @@
import { ClubExperience } from '@/components/club/ClubExperience';
export default function ClubPage() {
return <ClubExperience mode="landing" />;
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import { Open_Sans } from 'next/font/google'; import { Open_Sans } from 'next/font/google';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { Footer } from '@/components/layout/Footer'; import { Footer } from '@/components/layout/Footer';
@@ -16,8 +16,16 @@ export const metadata: Metadata = {
title: 'mercadodevida — Productos naturales y orgánicos', title: 'mercadodevida — Productos naturales y orgánicos',
description: description:
'Tienda online de productos naturales, orgánicos y saludables. Envío a toda España. Calidad certificada.', 'Tienda online de productos naturales, orgánicos y saludables. Envío a toda España. Calidad certificada.',
applicationName: 'mercadodevida',
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Club MdV',
},
icons: { icons: {
icon: '/images/favicon.png', icon: '/images/favicon.png',
apple: '/images/club-icon-192.png',
}, },
openGraph: { openGraph: {
title: 'mercadodevida — Productos naturales y orgánicos', title: 'mercadodevida — Productos naturales y orgánicos',
@@ -26,6 +34,10 @@ export const metadata: Metadata = {
}, },
}; };
export const viewport: Viewport = {
themeColor: '#70AD47',
};
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="es" suppressHydrationWarning className={opensans.variable}> <html lang="es" suppressHydrationWarning className={opensans.variable}>

View File

@@ -0,0 +1,30 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Club Mercado de Vida',
short_name: 'Club MdV',
description: 'Tarjeta digital y acceso rápido al Club de Clientes de Mercado de Vida.',
start_url: '/club',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#F5F9EF',
theme_color: '#70AD47',
categories: ['shopping', 'lifestyle'],
icons: [
{
src: '/images/club-icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable',
},
{
src: '/images/club-icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
};
}

View File

@@ -111,7 +111,7 @@ export default async function ProductPage({ params }: Props) {
(next/image `fill` needs a non-zero parent) and the image is (next/image `fill` needs a non-zero parent) and the image is
letterboxed with `object-contain`. */} letterboxed with `object-contain`. */}
<div className="flex justify-center"> <div className="flex justify-center">
<div className="relative w-full max-w-md aspect-[5/7] max-h-[500px] bg-gray-50 rounded-2xl border border-gray-100 overflow-hidden"> <div className="relative w-full max-w-md aspect-[5/7] max-h-[500px] bg-white rounded-2xl border border-gray-100 overflow-hidden">
{product.images?.[0] ? ( {product.images?.[0] ? (
<Image <Image
src={product.images[0].url} src={product.images[0].url}
@@ -201,6 +201,7 @@ export default async function ProductPage({ params }: Props) {
priceCents={grossCents} priceCents={grossCents}
imageUrl={product.images?.[0]?.url} imageUrl={product.images?.[0]?.url}
available={true} available={true}
availableQuantity={stock.availableQuantity}
minPurchaseQty={product.minPurchaseQty ?? 1} minPurchaseQty={product.minPurchaseQty ?? 1}
/> />
) : ( ) : (

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useCart } from '@/contexts/CartContext'; import { useCart } from '@/contexts/CartContext';
import { fetchStockAvailability } from '@/lib/api';
interface Props { interface Props {
variantId: string; variantId: string;
@@ -10,24 +11,48 @@ interface Props {
imageUrl?: string; imageUrl?: string;
available?: boolean; available?: boolean;
minPurchaseQty?: number; minPurchaseQty?: number;
/** F-138: max units available right now. Used to cap the add-to-cart action. */
availableQuantity?: number;
className?: string; className?: string;
} }
export default function AddToCartButton({ 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) { }: Props) {
const { addItem, itemCount } = useCart(); const { addItem, items } = useCart();
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [liveStock, setLiveStock] = useState<number | null>(availableQuantity ?? null);
const qty = Math.max(1, minPurchaseQty); 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 = () => { const handleAdd = () => {
if (!available) return; if (disabled) return;
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty }); addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
setAdded(true); setAdded(true);
setTimeout(() => setAdded(false), 2000); setTimeout(() => setAdded(false), 2000);
}; };
if (!available) { if (!available || remaining === 0) {
return ( return (
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}> <button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
Agotado Agotado
@@ -47,9 +72,10 @@ export default function AddToCartButton({
<div> <div>
<button <button
onClick={handleAdd} 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> </button>
{qty > 1 && ( {qty > 1 && (
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p> <p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useCart, type CartItem } from '@/contexts/CartContext'; import { useCart, type CartItem } from '@/contexts/CartContext';
import { fetchStockAvailability } from '@/lib/api';
function formatPrice(cents: number) { function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`; return `${(cents / 100).toFixed(2)}`;
@@ -9,6 +11,33 @@ function formatPrice(cents: number) {
function CartItemRow({ item }: { item: CartItem }) { function CartItemRow({ item }: { item: CartItem }) {
const { removeItem, changeQuantity } = useCart(); 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 ( return (
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0"> <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> </button>
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span> <span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
<button <button
onClick={() => changeQuantity(item.variantId, item.quantity + 1)} onClick={() => {
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors" 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> </button>
@@ -62,6 +101,11 @@ function CartItemRow({ item }: { item: CartItem }) {
Eliminar Eliminar
</button> </button>
</div> </div>
{stockLabel && (
<p className={`mt-1 text-xs ${atStockMax ? 'text-amber-600' : 'text-gray-500'}`}>
{stockLabel}
</p>
)}
</div> </div>
{/* Subtotal */} {/* Subtotal */}

View File

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

View File

@@ -230,10 +230,10 @@ export default function CheckoutClient() {
<h2 className="font-bold text-gray-900 mb-1">¿Ya tienes cuenta?</h2> <h2 className="font-bold text-gray-900 mb-1">¿Ya tienes cuenta?</h2>
<p className="text-sm text-gray-600 mb-3">Inicia sesión para una experiencia más rápida.</p> <p className="text-sm text-gray-600 mb-3">Inicia sesión para una experiencia más rápida.</p>
<div className="flex gap-3"> <div className="flex gap-3">
<Link href="/auth/login" className="px-4 py-2 bg-[#70ad47] hover:bg-[#5a9040] text-white text-sm font-semibold rounded-lg transition-colors"> <Link href="/auth/login?returnTo=%2Fcheckout" className="px-4 py-2 bg-[#70ad47] hover:bg-[#5a9040] text-white text-sm font-semibold rounded-lg transition-colors">
Iniciar sesión Iniciar sesión
</Link> </Link>
<Link href="/auth/register" className="px-4 py-2 border border-gray-300 hover:border-[#70ad47] text-gray-700 text-sm font-semibold rounded-lg transition-colors"> <Link href="/auth/register?returnTo=%2Fcheckout" className="px-4 py-2 border border-gray-300 hover:border-[#70ad47] text-gray-700 text-sm font-semibold rounded-lg transition-colors">
Crear cuenta Crear cuenta
</Link> </Link>
</div> </div>

View File

@@ -0,0 +1,513 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import qrcode from 'qrcode';
import { useEffect, useMemo, useState } from 'react';
type ClubMode = 'landing' | 'join' | 'card';
type ClubConfig = {
clubEnabled: boolean;
cashbackBps: number;
cashbackPercentage: number;
allowAnonymousMembers: boolean;
allowRecoveryCodes: boolean;
minimumRedeemAmountCents: number;
};
type ClubMember = {
id: string;
userId: string | null;
memberCode: string;
status: 'active' | 'blocked' | 'merged';
tierCode: string;
currentBalanceCents: number;
isAnonymous: boolean;
createdAt: string;
updatedAt: string;
};
type ClubMovement = {
id: string;
type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
amountCents: number;
balanceDeltaCents: number;
createdAt: string;
};
type ClubMeResponse = {
member: ClubMember;
config: ClubConfig;
};
type ClubMovementsResponse = {
member: ClubMember;
items: ClubMovement[];
};
type ClubJoinResponse = {
created: boolean;
deviceToken: string;
member: ClubMember;
config: ClubConfig;
};
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
};
function formatMoney(cents: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
}).format(cents / 100);
}
function movementLabel(type: ClubMovement['type']): string {
switch (type) {
case 'earn':
return 'Acumulación';
case 'redeem':
return 'Canje';
case 'refund':
return 'Reembolso';
case 'bonus':
return 'Bono';
case 'adjustment':
return 'Ajuste';
default:
return type;
}
}
async function readJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, { ...init, credentials: 'include', cache: 'no-store' });
const data = (await response.json().catch(() => ({}))) as { error?: { message?: string } } & T;
if (!response.ok) {
throw new Error(data.error?.message || 'No se pudo completar la operación del Club');
}
return data;
}
function isDisplayStandalone(): boolean {
if (typeof window === 'undefined') return false;
const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean };
return window.matchMedia('(display-mode: standalone)').matches || navigatorWithStandalone.standalone === true;
}
function isIosDevice(): boolean {
if (typeof window === 'undefined') return false;
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
}
function buildVisualCode(_seed: string): boolean[][] {
// DEPRECATED: replaced by MemberQrCode component using real QR codes.
// Kept as a no-op stub for backward compatibility (tests, etc.).
const size = 21;
return Array.from({ length: size }, () => Array.from({ length: size }, () => false));
}
function InstallButton() {
const [installEvent, setInstallEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(false);
useEffect(() => {
setInstalled(isDisplayStandalone());
const handlePrompt = (event: Event) => {
event.preventDefault();
setInstallEvent(event as BeforeInstallPromptEvent);
};
const handleInstalled = () => {
setInstalled(true);
setInstallEvent(null);
};
window.addEventListener('beforeinstallprompt', handlePrompt);
window.addEventListener('appinstalled', handleInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handlePrompt);
window.removeEventListener('appinstalled', handleInstalled);
};
}, []);
if (installed) {
return (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
La app del Club ya está instalada en este dispositivo.
</div>
);
}
if (installEvent) {
return (
<button
type="button"
onClick={async () => {
await installEvent.prompt();
await installEvent.userChoice.catch(() => undefined);
setInstallEvent(null);
}}
className="inline-flex items-center justify-center rounded-full bg-[#1B4332] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#163826]"
>
Instalar app del Club
</button>
);
}
if (isIosDevice()) {
return (
<div className="rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600">
En iPhone o iPad puedes instalarla desde Compartir <strong>Añadir a pantalla de inicio</strong>.
</div>
);
}
return null;
}
function MemberVisualCode({ memberCode }: { memberCode: string }) {
const [svg, setSvg] = useState<string>('');
useEffect(() => {
let cancelled = false;
qrcode
.toString(memberCode, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 2,
width: 220,
color: { dark: '#1B4332', light: '#ffffff' },
})
.then((output) => {
if (!cancelled) setSvg(output);
})
.catch(() => {
if (!cancelled) setSvg('');
});
return () => {
cancelled = true;
};
}, [memberCode]);
if (!svg) return null;
return (
<div
className="h-44 w-44 rounded-3xl bg-white p-3 shadow-inner [&_svg]:h-full [&_svg]:w-full"
role="img"
aria-label={`Código QR del socio ${memberCode}`}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
function MovementList({ items }: { items: ClubMovement[] }) {
if (items.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-8 text-center text-sm text-stone-500">
Aún no hay movimientos. Cuando empieces a usar el Club aparecerán aquí.
</div>
);
}
return (
<div className="space-y-3">
{items.map((item) => {
const positive = item.balanceDeltaCents > 0;
return (
<div
key={item.id}
className="flex items-center justify-between rounded-2xl border border-stone-200 bg-white px-4 py-3"
>
<div>
<p className="text-sm font-semibold text-stone-900">{movementLabel(item.type)}</p>
<p className="text-xs text-stone-500">
{new Date(item.createdAt).toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div className="text-right">
<p className={`text-sm font-semibold ${positive ? 'text-emerald-700' : 'text-stone-700'}`}>
{positive ? '+' : ''}{formatMoney(item.balanceDeltaCents)}
</p>
<p className="text-xs text-stone-500">Base {formatMoney(item.amountCents)}</p>
</div>
</div>
);
})}
</div>
);
}
export function ClubExperience({ mode }: { mode: ClubMode }) {
const router = useRouter();
const [config, setConfig] = useState<ClubConfig | null>(null);
const [member, setMember] = useState<ClubMember | null>(null);
const [movements, setMovements] = useState<ClubMovement[]>([]);
const [loading, setLoading] = useState(true);
const [joining, setJoining] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
try {
const publicConfig = await readJson<ClubConfig>('/api/club/config');
if (cancelled) return;
setConfig(publicConfig);
try {
const me = await readJson<ClubMeResponse>('/api/club/me');
if (cancelled) return;
setMember(me.member);
setConfig(me.config);
const latest = await readJson<ClubMovementsResponse>('/api/club/movements?limit=5');
if (cancelled) return;
setMovements(latest.items ?? []);
} catch {
if (cancelled) return;
setMember(null);
setMovements([]);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'No se pudo cargar el Club');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const handleJoin = async () => {
setJoining(true);
setError('');
try {
const response = await readJson<ClubJoinResponse>('/api/club/join', { method: 'POST' });
setMember(response.member);
setConfig(response.config);
setMovements([]);
router.push('/club/card');
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo darte de alta en el Club');
} finally {
setJoining(false);
}
};
const benefits = [
`Cashback configurable actualmente al ${config?.cashbackPercentage ?? 2}%`,
'Tarjeta digital siempre disponible en tu móvil',
'Historial de movimientos y saldo desde la misma app',
];
if (loading) {
return (
<section className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
<div className="rounded-[2rem] border border-stone-200 bg-white px-6 py-16 text-center text-stone-500 shadow-sm">
Cargando Club
</div>
</section>
);
}
if (error && !config) {
return (
<section className="mx-auto max-w-4xl px-4 py-16 sm:px-6 lg:px-8">
<div className="rounded-[2rem] border border-red-200 bg-red-50 px-6 py-16 text-center text-red-700 shadow-sm">
{error}
</div>
</section>
);
}
const disabled = config && (!config.clubEnabled || !config.allowAnonymousMembers);
return (
<section className="bg-[radial-gradient(circle_at_top,_rgba(112,173,71,0.18),_transparent_45%)]">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 lg:px-8">
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[2rem] bg-[#163826] p-8 text-white shadow-xl">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-[#CFE7B8]">Club Mercado de Vida</p>
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">
{mode === 'card' ? 'Tu tarjeta digital del Club' : 'Lleva tu Club siempre contigo'}
</h1>
<p className="mt-4 max-w-xl text-sm leading-7 text-[#E8F5E0] sm:text-base">
Únete en segundos, consulta tu saldo y abre tu tarjeta del Club desde el móvil como una app instalada.
</p>
<div className="mt-8 flex flex-wrap gap-3">
{member ? (
<Link
href="/club/card"
className="inline-flex items-center justify-center rounded-full bg-white px-5 py-3 text-sm font-semibold text-[#163826] transition-colors hover:bg-[#F5F9EF]"
>
Abrir mi tarjeta
</Link>
) : (
<button
type="button"
onClick={handleJoin}
disabled={joining || Boolean(disabled)}
className="inline-flex items-center justify-center rounded-full bg-white px-5 py-3 text-sm font-semibold text-[#163826] transition-colors hover:bg-[#F5F9EF] disabled:cursor-not-allowed disabled:opacity-60"
>
{joining ? 'Activando…' : 'Unirme al Club'}
</button>
)}
<Link
href="/club/join"
className="inline-flex items-center justify-center rounded-full border border-white/30 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/10"
>
Ver acceso rápido
</Link>
</div>
{error && (
<div className="mt-4 rounded-2xl border border-red-300/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">
{error}
</div>
)}
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{benefits.map((benefit) => (
<div key={benefit} className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4 text-sm text-[#E8F5E0]">
{benefit}
</div>
))}
</div>
</div>
<div className="space-y-6">
<div className="rounded-[2rem] border border-stone-200 bg-[#F5F9EF] p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-[#70AD47]">Club card</p>
<h2 className="mt-2 text-2xl font-bold text-stone-900">
{member ? member.memberCode : 'Activa tu tarjeta'}
</h2>
<p className="mt-2 text-sm text-stone-600">
{member
? 'Tu tarjeta digital queda guardada en este dispositivo para abrirla al instante.'
: 'Date de alta una vez y tendrás tu credencial digital siempre a mano.'}
</p>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-stone-600 shadow-sm">
{member ? 'Activa' : 'Lista para activar'}
</span>
</div>
<div className="mt-6 rounded-[2rem] bg-gradient-to-br from-[#70AD47] via-[#5A9040] to-[#2D6A4F] p-5 text-white shadow-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.25em] text-white/80">Saldo Club</p>
<p className="mt-2 text-3xl font-bold">{formatMoney(member?.currentBalanceCents ?? 0)}</p>
<p className="mt-2 text-sm text-white/80">Canje mínimo: {formatMoney(config?.minimumRedeemAmountCents ?? 500)}</p>
</div>
<MemberVisualCode memberCode={member?.memberCode ?? 'MDV-CLUB-DEMO'} />
</div>
<div className="mt-4 flex items-center justify-between rounded-2xl bg-black/10 px-4 py-3 text-sm text-white/90">
<span>{member?.memberCode ?? 'Activa tu member code'}</span>
<span>{member?.isAnonymous ? 'Modo anónimo' : 'Cuenta vinculada'}</span>
</div>
</div>
</div>
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-lg font-bold text-stone-900">Instalación rápida</h3>
<p className="mt-1 text-sm text-stone-600">Añade el Club a tu pantalla de inicio para abrir la tarjeta como una app.</p>
</div>
</div>
<div className="mt-4">
<InstallButton />
</div>
</div>
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-stone-900">
{mode === 'join' ? 'Alta rápida' : 'Estado del Club'}
</h2>
<p className="mt-1 text-sm text-stone-600">
{disabled
? 'El Club está temporalmente desactivado desde configuración.'
: member
? 'Tu dispositivo ya tiene una identidad Club activa.'
: 'Puedes darte de alta en segundos desde este dispositivo.'}
</p>
</div>
{member && (
<Link href="/club/card" className="text-sm font-semibold text-[#2D6A4F] hover:text-[#1B4332]">
Abrir tarjeta
</Link>
)}
</div>
<div className="mt-5 space-y-3 text-sm text-stone-700">
<div className="rounded-2xl bg-stone-50 px-4 py-3">
<span className="font-semibold text-stone-900">Cashback configurado:</span>{' '}
{config?.cashbackPercentage ?? 2}%
</div>
<div className="rounded-2xl bg-stone-50 px-4 py-3">
<span className="font-semibold text-stone-900">Recovery codes:</span>{' '}
{config?.allowRecoveryCodes ? 'preparados para fases futuras' : 'desactivados'}
</div>
<div className="rounded-2xl bg-stone-50 px-4 py-3">
<span className="font-semibold text-stone-900">Identidad actual:</span>{' '}
{member ? member.memberCode : 'sin activar todavía'}
</div>
</div>
{!member && (
<div className="mt-6 space-y-3">
<button
type="button"
onClick={handleJoin}
disabled={joining || Boolean(disabled)}
className="inline-flex w-full items-center justify-center rounded-2xl bg-[#70AD47] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#5A9040] disabled:cursor-not-allowed disabled:opacity-60"
>
{joining ? 'Creando tarjeta…' : 'Crear mi tarjeta del Club'}
</button>
{disabled && (
<p className="text-sm text-stone-500">
Ahora mismo no se permiten nuevas altas anónimas desde la configuración del Club.
</p>
)}
</div>
)}
</div>
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-bold text-stone-900">Últimos movimientos</h2>
<p className="mt-1 text-sm text-stone-600">Los eventos del ledger del Club aparecerán aquí.</p>
</div>
{member && (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-600">
{movements.length} visibles
</span>
)}
</div>
<div className="mt-5">
<MovementList items={member ? movements : []} />
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -41,6 +41,7 @@ export function Footer() {
['/categories', 'Categorías'], ['/categories', 'Categorías'],
['/brands', 'Marcas'], ['/brands', 'Marcas'],
['/search', 'Buscar'], ['/search', 'Buscar'],
['/club', 'Club'],
].map(([href, label]) => ( ].map(([href, label]) => (
<li key={href}> <li key={href}>
<Link href={href} <Link href={href}

View File

@@ -232,6 +232,9 @@ export function Header() {
<Link href="/brands" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors"> <Link href="/brands" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Marcas Marcas
</Link> </Link>
<Link href="/club" className="text-sm font-medium text-gray-700 hover:text-[#70ad47] transition-colors">
Club
</Link>
</nav> </nav>
{/* Cart + user */} {/* Cart + user */}

View File

@@ -5,10 +5,12 @@
*/ */
export const up = (pgm) => { export const up = (pgm) => {
pgm.addColumn('catalog_product_variants', 'weight_grams', { pgm.addColumn('catalog_product_variants', {
weight_grams: {
type: 'integer', type: 'integer',
notNull: false, notNull: false,
default: null, default: null,
},
}); });
}; };

View File

@@ -6,20 +6,22 @@
*/ */
export const up = (pgm) => { export const up = (pgm) => {
pgm.addColumn('identity_users', 'confirmation_token', { pgm.addColumn('identity_users', {
type: 'string', confirmation_token: {
type: 'text',
notNull: false, notNull: false,
default: null, default: null,
}); },
pgm.addColumn('identity_users', 'confirmed_at', { confirmed_at: {
type: 'timestamp', type: 'timestamptz',
notNull: false, notNull: false,
default: null, default: null,
}); },
pgm.addColumn('identity_users', 'email_confirmed', { email_confirmed: {
type: 'boolean', type: 'boolean',
notNull: true, notNull: true,
default: false, default: false,
},
}); });
// FEAT-199: migrate existing users to confirmed (they already verified their email during signup) // FEAT-199: migrate existing users to confirmed (they already verified their email during signup)
pgm.sql('UPDATE identity_users SET email_confirmed = true'); pgm.sql('UPDATE identity_users SET email_confirmed = true');

View File

@@ -0,0 +1,17 @@
/**
* Compatibility marker for the historical payment partial-refund migration.
*
* Some long-lived local databases have this migration recorded in pgmigrations,
* but the repository no longer carried the file. node-pg-migrate rejects later
* migrations when an already-run migration is missing from disk, so keep this
* no-op marker to preserve ordering between 060 and 062.
*
* Current partial-refund schema support is already covered by previous
* reporting/POS migrations (notably 053 and 056).
*/
/** @param {import('pg-migrate').MigrationBuilder} _pgm */
export const up = (_pgm) => {};
/** @param {import('pg-migrate').MigrationBuilder} _pgm */
export const down = (_pgm) => {};

View File

@@ -0,0 +1,17 @@
/**
* Add twitter_url and pinterest_url columns to store_settings table.
*/
/** @param {import('pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.addColumn('store_settings', {
twitter_url: { type: 'text', notNull: false, default: null },
pinterest_url: { type: 'text', notNull: false, default: null },
});
};
/** @param {import('pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.dropColumn('store_settings', 'twitter_url');
pgm.dropColumn('store_settings', 'pinterest_url');
};

View File

@@ -0,0 +1,20 @@
'use strict';
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.addColumns('pos_stores', {
logo_url: {
type: 'text',
notNull: false,
default: null,
},
});
pgm.sql(
"COMMENT ON COLUMN pos_stores.logo_url IS 'URL del logo custom para tickets TPV. Si es null, se usa el logo default /images/logo-main.png'",
);
};
exports.down = (pgm) => {
pgm.dropColumns('pos_stores', ['logo_url']);
};

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

@@ -0,0 +1,147 @@
/**
* CLUB-001 — Club de Clientes core backend.
*
* Phase 1 schema:
* - anonymous members + device tokens
* - ledger transactions as source of truth
* - future-ready recovery/campaign/reward tables
* - club config seeds in store_settings
*/
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES identity_users(id) ON DELETE SET NULL,
member_code text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'active',
tier_code text NOT NULL DEFAULT 'base',
current_balance_cents integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_members_status_check CHECK (status IN ('active', 'blocked', 'merged')),
CONSTRAINT club_members_balance_non_negative CHECK (current_balance_cents >= 0),
CONSTRAINT club_members_member_code_format CHECK (member_code ~ '^MDV-[A-Z0-9]{8}$')
)
`);
pgm.sql(`
CREATE UNIQUE INDEX IF NOT EXISTS club_members_user_id_unique_idx
ON club_members (user_id)
WHERE user_id IS NOT NULL
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
device_token_hash text NOT NULL UNIQUE,
last_used_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_devices_member_id_idx ON club_devices (member_id)`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
kind text NOT NULL DEFAULT 'generic',
status text NOT NULL DEFAULT 'draft',
config jsonb NOT NULL DEFAULT '{}'::jsonb,
starts_at timestamptz,
ends_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_campaigns_status_check CHECK (status IN ('draft', 'active', 'archived'))
)
`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
sale_id uuid REFERENCES orders_orders(id) ON DELETE SET NULL,
store_id uuid REFERENCES pos_stores(id) ON DELETE SET NULL,
type text NOT NULL,
amount_cents integer NOT NULL DEFAULT 0,
balance_delta_cents integer NOT NULL,
idempotency_key text UNIQUE,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_transactions_type_check CHECK (type IN ('earn', 'redeem', 'refund', 'bonus', 'adjustment')),
CONSTRAINT club_transactions_amount_non_negative CHECK (amount_cents >= 0),
CONSTRAINT club_transactions_balance_delta_non_zero CHECK (balance_delta_cents <> 0)
)
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS club_transactions_member_id_created_at_idx
ON club_transactions (member_id, created_at DESC)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_transactions_sale_id_idx ON club_transactions (sale_id)`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_recovery_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
code_hash text NOT NULL,
code_fingerprint text NOT NULL UNIQUE,
used_at timestamptz,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_recovery_codes_member_id_idx ON club_recovery_codes (member_id)`);
pgm.sql(`
CREATE TABLE IF NOT EXISTS club_rewards (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES club_members(id) ON DELETE CASCADE,
campaign_id uuid REFERENCES club_campaigns(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'available',
label text NOT NULL,
amount_cents integer,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT club_rewards_status_check CHECK (status IN ('available', 'redeemed', 'expired', 'cancelled'))
)
`);
pgm.sql(`CREATE INDEX IF NOT EXISTS club_rewards_member_id_idx ON club_rewards (member_id)`);
pgm.sql(`
INSERT INTO store_settings (key, value) VALUES
('club_enabled', 'true'),
('club_cashback_bps', '200'),
('club_allow_anonymous_members', 'true'),
('club_allow_recovery_codes', 'true'),
('club_minimum_redeem_cents', '500')
ON CONFLICT (key) DO NOTHING
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql(`DELETE FROM store_settings WHERE key IN (
'club_enabled',
'club_cashback_bps',
'club_allow_anonymous_members',
'club_allow_recovery_codes',
'club_minimum_redeem_cents'
)`);
pgm.sql('DROP INDEX IF EXISTS club_rewards_member_id_idx');
pgm.sql('DROP TABLE IF EXISTS club_rewards');
pgm.sql('DROP INDEX IF EXISTS club_recovery_codes_member_id_idx');
pgm.sql('DROP TABLE IF EXISTS club_recovery_codes');
pgm.sql('DROP INDEX IF EXISTS club_transactions_sale_id_idx');
pgm.sql('DROP INDEX IF EXISTS club_transactions_member_id_created_at_idx');
pgm.sql('DROP TABLE IF EXISTS club_transactions');
pgm.sql('DROP TABLE IF EXISTS club_campaigns');
pgm.sql('DROP INDEX IF EXISTS club_devices_member_id_idx');
pgm.sql('DROP TABLE IF EXISTS club_devices');
pgm.sql('DROP INDEX IF EXISTS club_members_user_id_unique_idx');
pgm.sql('DROP TABLE IF EXISTS club_members');
};

View File

@@ -0,0 +1,19 @@
/**
* CLUB-004 — Club recovery codes: add index on code_fingerprint.
*
* The consumeRecoveryCode() flow looks up by fingerprint, which must be fast.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
CREATE INDEX IF NOT EXISTS club_recovery_codes_code_fingerprint_idx
ON club_recovery_codes (code_fingerprint)
WHERE used_at IS NULL
`);
};
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
export const down = (pgm) => {
pgm.sql('DROP INDEX IF EXISTS club_recovery_codes_code_fingerprint_idx');
};

View File

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

View File

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

View File

@@ -130,6 +130,69 @@ port_pid() {
fi fi
} }
port_pids() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u || true
fi
}
stop_pid() {
local pid="$1" label="$2" deadline
[[ -n "$pid" ]] || return 0
if ! kill -0 "$pid" 2>/dev/null; then
return 0
fi
echo "[INFO] Stopping $label (PID $pid)..."
kill -TERM "$pid" 2>/dev/null || true
deadline=$((SECONDS + 15))
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 1; done
if kill -0 "$pid" 2>/dev/null; then
echo "[WARN] $label did not stop gracefully; sending KILL"
kill -KILL "$pid" 2>/dev/null || true
fi
}
stop_service_port_listeners() {
local service="$1" port pid found=0
port="$(service_port "$service")"
while IFS= read -r pid; do
[[ -n "$pid" ]] || continue
found=1
stop_pid "$pid" "stale $service listener on port $port"
done < <(port_pids "$port")
if [[ "$found" == 1 ]]; then
rm -f "$(pid_file "$service")"
fi
}
stop_stale_project_processes() {
local pid cmd
while IFS= read -r line; do
pid="${line%% *}"
cmd="${line#* }"
[[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]] || continue
[[ "$pid" == "$$" || "$pid" == "$BASHPID" ]] && continue
case "$cmd" in
*"$PROJECT_DIR"*"tsx"*"watch src/infrastructure/http/server.ts"*|\
*"$PROJECT_DIR"*"node_modules/tsx/dist/cli.mjs watch src/infrastructure/http/server.ts"*|\
*"$PROJECT_DIR"*"next start"*|\
*"$PROJECT_DIR"*"node_modules/next/dist/bin/next start"*)
stop_pid "$pid" "stale project process"
;;
esac
done < <(ps -axo pid=,command= 2>/dev/null | sed 's/^ *//')
}
clear_stale_deployments() {
echo '[INFO] Clearing old service deployments from managed ports...'
stop_stale_project_processes
local service
for service in "${SERVICES[@]}"; do
stop_service_port_listeners "$service"
done
}
assert_port_available() { assert_port_available() {
local service="$1" port existing managed local service="$1" port existing managed
port="$(service_port "$service")" port="$(service_port "$service")"
@@ -299,13 +362,82 @@ spawn_service() {
fi fi
} }
# Markers exclusive to next dev (Turbopack). Any hit means the server is
# serving the dev build on a port that prod should own. Add new markers here
# if a future Next.js version uses different ones.
HMR_MARKERS=(
'/__next_hmr'
'react-refresh'
'Download the React DevTools'
'webpack-hmr'
'__webpack_require__'
'_devPagesManifest'
)
# Services powered by Next.js (backend uses Fastify, so it is excluded).
NEXT_SERVICES=(admin tpv frontend storefront)
# smoke_check_no_dev_markers
# For each Next.js service in prod mode, verify the server is NOT next dev:
# 1. Root HTML must not contain any HMR_MARKERS substring.
# 2. GET /_next/hmr must respond 404 or 426 (not 200/101/405).
# 3. Startup log must not contain "(Turbopack)".
# Returns 0 if clean, 1 if any check fails.
smoke_check_no_dev_markers() {
local service port url log body hmr_code marker failed=0
echo '[INFO] Smoke check: verifying Next.js services are NOT serving next dev (HMR-free)...'
for service in "${NEXT_SERVICES[@]}"; do
port="$(service_port "$service")"
url="$(service_url "$service")"
log="$(log_file "$service")"
# 1) HTML root must not contain dev-only markers
if body="$(curl --max-time 5 -fsS "$url" 2>/dev/null)"; then
for marker in "${HMR_MARKERS[@]}"; do
if printf '%s' "$body" | grep -qF "$marker"; then
echo "[FAIL] $service root HTML contains dev-only marker: $marker" >&2
failed=1
fi
done
else
echo "[FAIL] $service root not reachable at $url" >&2
failed=1
fi
# 2) HMR endpoint must reject (404 or 426 expected in prod)
hmr_code="$(curl --max-time 5 -sS -o /dev/null -w '%{http_code}' "$url/_next/hmr" 2>/dev/null || true)"
case "$hmr_code" in
404|426) ;;
'')
echo "[FAIL] $service /_next/hmr did not respond (curl returned empty)" >&2
failed=1
;;
*)
echo "[FAIL] $service /_next/hmr returned HTTP $hmr_code (expected 404 or 426; a dev server is likely active)" >&2
failed=1
;;
esac
# 3) Startup banner must not include "(Turbopack)"
if [[ -f "$log" ]] && head -20 "$log" 2>/dev/null | grep -qF '(Turbopack)'; then
echo "[FAIL] $service startup banner shows (Turbopack) — dev server is active" >&2
failed=1
fi
done
if (( failed )); then
echo '[FAIL] DEV MODE DETECTED on prod start. Refusing to continue.' >&2
echo ' Stop every next dev process on these ports and rerun.' >&2
echo ' See docs/HOWTO-monolith.md §3.1 for the recovery runbook.' >&2
return 1
fi
echo '[OK] All Next.js services are HMR-free (prod start confirmed).'
return 0
}
start_all() { start_all() {
ensure_env ensure_env
clear_stale_deployments
for service in "${SERVICES[@]}"; do for service in "${SERVICES[@]}"; do
if is_running "$service"; then
echo "[FAIL] $service is already managed in $MODE mode; use restart" >&2
exit 1
fi
rm -f "$(pid_file "$service")" rm -f "$(pid_file "$service")"
assert_port_available "$service" assert_port_available "$service"
done done
@@ -315,29 +447,21 @@ start_all() {
[[ "$MODE" == "prod" ]] && build_prod [[ "$MODE" == "prod" ]] && build_prod
sync_uploads sync_uploads
for service in "${SERVICES[@]}"; do spawn_service "$service"; done for service in "${SERVICES[@]}"; do spawn_service "$service"; done
if [[ "$MODE" == "prod" ]]; then
if ! smoke_check_no_dev_markers; then
echo '[FAIL] Smoke check failed; stopping just-started services to leave a clean state.' >&2
stop_all
exit 1
fi
fi
echo echo
print_urls print_urls
} }
stop_service() { stop_service() {
local service="$1" pid deadline local service="$1" pid
pid="$(read_pid "$service")" pid="$(read_pid "$service")"
if [[ -z "$pid" ]]; then stop_pid "$pid" "$service"
rm -f "$(pid_file "$service")"
return
fi
if ! kill -0 "$pid" 2>/dev/null; then
rm -f "$(pid_file "$service")"
return
fi
echo "[INFO] Stopping $service (PID $pid)..."
kill -TERM "$pid" 2>/dev/null || true
deadline=$((SECONDS + 15))
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 1; done
if kill -0 "$pid" 2>/dev/null; then
echo "[WARN] $service did not stop gracefully; sending KILL"
kill -KILL "$pid" 2>/dev/null || true
fi
rm -f "$(pid_file "$service")" rm -f "$(pid_file "$service")"
} }

View File

@@ -35,6 +35,7 @@ import { createPromotionService, registerPromotionsRoutes } from '../modules/pro
import { registerCartRoutes } from '../modules/cart/index.js'; import { registerCartRoutes } from '../modules/cart/index.js';
import { registerShippingRoutes } from '../modules/shipping/index.js'; import { registerShippingRoutes } from '../modules/shipping/index.js';
import { registerOrdersRoutes } from '../modules/orders/index.js'; import { registerOrdersRoutes } from '../modules/orders/index.js';
import { registerClubRoutes } from '../modules/club/index.js';
import { registerPosRoutes } from '../modules/pos/index.js'; import { registerPosRoutes } from '../modules/pos/index.js';
import { registerCheckoutRoutes } from '../modules/checkout/index.js'; import { registerCheckoutRoutes } from '../modules/checkout/index.js';
import { registerPaymentsRoutes } from '../modules/payments/index.js'; import { registerPaymentsRoutes } from '../modules/payments/index.js';
@@ -320,6 +321,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
}); });
}); });
await app.register(async (instance) => {
await registerClubRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: combinedAuth,
});
});
// POS routes (POS-004) // POS routes (POS-004)
if (deps.pool && combinedAuth) { if (deps.pool && combinedAuth) {
await app.register(async (instance) => { await app.register(async (instance) => {

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 productId = randomUUID();
const variantId = randomUUID(); const variantId = randomUUID();
await setPrice(variantId, 500); await setPrice(variantId, 500);
await setStock(variantId, 0); await setStock(variantId, 0);
await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/cart/items', url: '/cart/items',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie }, cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1 }, payload: { productId, variantId, quantity: 1 },
}); });
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
const cart = await app.inject({ const cart = await app.inject({
method: 'GET', method: 'GET',
url: '/cart', url: '/cart',
cookies: { [SESSION_COOKIE_NAME]: cookie }, cookies: { [SESSION_COOKIE_NAME]: cookie },
}); });
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find( const items = cart.json().items as Array<{ variantId: string }>;
(entry) => entry.variantId === variantId, expect(items.find((entry) => entry.variantId === variantId)).toBeUndefined();
); });
expect(item).toMatchObject({ available: false });
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 () => { it('ignores client-supplied price fields in cart payloads (AC3)', async () => {

View File

@@ -0,0 +1,191 @@
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { createClubService, CLUB_DEVICE_COOKIE_NAME } from '../../modules/club/index.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
function cookieValue(setCookieHeader: string | string[] | undefined): string {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
expect(raw).toBeDefined();
const pair = (raw as string).split(';')[0] as string;
return pair.slice(pair.indexOf('=') + 1);
}
describe.skipIf(!hasDb)('club backend phase 1 (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let adminCookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'club-admin@example.com', password: 'correct horse battery staple' };
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
const id = (registered.json() as { id: string }).id;
await pool.query(
`UPDATE identity_users
SET role = $1,
email_confirmed = true,
confirmed_at = now(),
confirmation_token = null
WHERE id = $2`,
['admin', id],
);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app?.close();
await pool?.end();
});
it('creates an anonymous member, reuses device identity and lists movements', async () => {
const config = await app.inject({ method: 'GET', url: '/club/config' });
expect(config.statusCode).toBe(200);
expect(config.json()).toMatchObject({ clubEnabled: true, cashbackBps: 200 });
const joined = await app.inject({ method: 'POST', url: '/club/join' });
expect(joined.statusCode).toBe(201);
expect(String(joined.headers['set-cookie'])).toContain(`${CLUB_DEVICE_COOKIE_NAME}=`);
const payload = joined.json() as {
created: boolean;
deviceToken: string;
member: { id: string; memberCode: string; currentBalanceCents: number };
};
expect(payload.created).toBe(true);
expect(payload.deviceToken).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(payload.member.memberCode).toMatch(/^MDV-[A-Z0-9]{8}$/);
expect(payload.member.currentBalanceCents).toBe(0);
const me = await app.inject({
method: 'GET',
url: '/club/me',
headers: { 'x-club-device-token': payload.deviceToken },
});
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({ member: { id: payload.member.id, memberCode: payload.member.memberCode } });
const repeatedJoin = await app.inject({
method: 'POST',
url: '/club/join',
cookies: { [CLUB_DEVICE_COOKIE_NAME]: payload.deviceToken },
});
expect(repeatedJoin.statusCode).toBe(200);
expect(repeatedJoin.json()).toMatchObject({ created: false, member: { id: payload.member.id } });
const club = createClubService(pool);
const first = await club.recordTransaction({
memberId: payload.member.id,
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
metadata: { reason: 'welcome' },
});
expect(first.created).toBe(true);
expect(first.member.currentBalanceCents).toBe(100);
const replay = await club.recordTransaction({
memberId: payload.member.id,
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
metadata: { reason: 'welcome' },
});
expect(replay.created).toBe(false);
expect(replay.member.currentBalanceCents).toBe(100);
const movements = await app.inject({
method: 'GET',
url: '/club/movements?limit=10',
headers: { 'x-club-device-token': payload.deviceToken },
});
expect(movements.statusCode).toBe(200);
expect(movements.json()).toMatchObject({
member: { id: payload.member.id, currentBalanceCents: 100 },
items: [
{
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
},
],
});
});
it('reads and updates admin club settings and blocks new joins when disabled', async () => {
const current = await app.inject({
method: 'GET',
url: '/admin/club/settings',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(current.statusCode).toBe(200);
expect(current.json()).toMatchObject({
clubEnabled: true,
cashbackBps: 200,
cashbackPercentage: 2,
allowAnonymousMembers: true,
allowRecoveryCodes: true,
minimumRedeemAmountCents: 500,
});
const updated = await app.inject({
method: 'PATCH',
url: '/admin/club/settings',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
clubEnabled: false,
cashbackPercentage: 3.5,
allowAnonymousMembers: false,
allowRecoveryCodes: false,
minimumRedeemAmountCents: 700,
},
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({
clubEnabled: false,
cashbackBps: 350,
cashbackPercentage: 3.5,
allowAnonymousMembers: false,
allowRecoveryCodes: false,
minimumRedeemAmountCents: 700,
});
const blocked = await app.inject({ method: 'POST', url: '/club/join' });
expect(blocked.statusCode).toBe(409);
expect(blocked.json()).toMatchObject({ error: { code: 'CLUB_DISABLED' } });
});
});

View File

@@ -0,0 +1,186 @@
import { randomUUID } from 'node:crypto';
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
function cookieValue(setCookieHeader: string | string[] | undefined): string {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
expect(raw).toBeDefined();
const pair = (raw as string).split(';')[0] as string;
return pair.slice(pair.indexOf('=') + 1);
}
describe.skipIf(!hasDb)('inventory admin overview (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let adminCookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'inventory-admin@example.com', password: 'correct horse battery staple' };
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
const id = (registered.json() as { id: string }).id;
await pool.query(
`UPDATE identity_users
SET role = $1,
email_confirmed = true,
confirmed_at = now(),
confirmation_token = null
WHERE id = $2`,
['admin', id],
);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app?.close();
await pool?.end();
});
it('returns paginated inventory rows with server-side search, filters and stats', async () => {
const createProduct = async (name: string, slug: string, expirationDate?: string | null) => {
const product = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name, slug, state: 'active', expirationDate: expirationDate ?? null },
});
expect(product.statusCode).toBe(201);
return product.json() as { id: string };
};
const productA = await createProduct('Avena bio', `avena-${randomUUID().slice(0, 8)}`);
const productB = await createProduct(
'Semillas lino',
`lino-${randomUUID().slice(0, 8)}`,
new Date(Date.now() + 10 * 86_400_000).toISOString().slice(0, 10),
);
const createVariant = async (productId: string, sku: string, ean: string) => {
const response = await app.inject({
method: 'POST',
url: `/products/${productId}/variants`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { sku, ean },
});
expect(response.statusCode).toBe(201);
return response.json() as { id: string };
};
const variantA = await createVariant(productA.id, `SKU-${randomUUID().slice(0, 8)}`, '111');
const variantB = await createVariant(productB.id, `SKU-${randomUUID().slice(0, 8)}`, '222');
const setPrice = async (
variantId: string,
netUnitAmountCents: number,
costCents: number,
) => {
const response = await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { netUnitAmountCents, costCents, vatRate: 'general' },
});
expect(response.statusCode).toBe(200);
};
const setStock = async (variantId: string, quantity: number) => {
const response = await app.inject({
method: 'PUT',
url: `/inventory/${variantId}/stock`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { quantity },
});
expect(response.statusCode).toBe(200);
};
await setPrice(variantA.id, 1000, 600);
await setPrice(variantB.id, 1000, 950);
await setStock(variantA.id, 8);
await setStock(variantB.id, 2);
const search = await app.inject({
method: 'GET',
url: '/inventory/admin/overview?q=111&limit=10&offset=0',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(search.statusCode).toBe(200);
expect(search.json()).toMatchObject({
total: 1,
stats: { inStock: 1, lowStock: 0, outOfStock: 0 },
items: [
{
productName: 'Avena bio',
variant: { ean: '111' },
stock: { availableQuantity: 8 },
},
],
});
const lowMargin = await app.inject({
method: 'GET',
url: '/inventory/admin/overview?filter=low_margin&limit=10&offset=0',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(lowMargin.statusCode).toBe(200);
expect(lowMargin.json()).toMatchObject({
total: 1,
items: [
{
productName: 'Semillas lino',
stock: { availableQuantity: 2 },
marginPercent: 21,
},
],
});
const expiring = await app.inject({
method: 'GET',
url: '/inventory/admin/overview?filter=expiring&limit=10&offset=0',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(expiring.statusCode).toBe(200);
const expiringBody = expiring.json() as {
total: number;
items: Array<{ productName: string }>;
};
expect(expiringBody.total).toBe(2);
expect(expiringBody.items.every((item) => item.productName === 'Semillas lino')).toBe(true);
});
});

View File

@@ -79,25 +79,25 @@ describe.skipIf(!hasDb)('F-186 POS checkout and receipts (real PostgreSQL)', ()
], ],
payments: [ payments: [
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1000 }, { methodCode: 'cash', amountCents: 500, tenderedCents: 1000 },
{ methodCode: 'card', amountCents: 700 }, { methodCode: 'card', amountCents: 770 },
], ],
}; };
const result = await useCase.execute(input); const result = await useCase.execute(input);
expect(result.totalCents).toBe(1200); expect(result.totalCents).toBe(1270);
expect(result.changeCents).toBe(500); expect(result.changeCents).toBe(500);
expect(result.receiptNumber).toBe('TPV-000001'); expect(result.receiptNumber).toBe('TPV-000001');
expect(result.receipt.items).toEqual( expect(result.receipt.items).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 700 }), expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 770, taxCents: 70 }),
expect.objectContaining({ name: 'Servicio libre', freeItem: true, totalCents: 500 }), expect.objectContaining({ name: 'Servicio libre', freeItem: true, totalCents: 500 }),
]), ]),
); );
expect(result.receipt.payments).toEqual( expect(result.receipt.payments).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ methodCode: 'cash', amountCents: 500, changeCents: 500 }), expect.objectContaining({ methodCode: 'cash', amountCents: 500, changeCents: 500 }),
expect.objectContaining({ methodCode: 'card', amountCents: 700 }), expect.objectContaining({ methodCode: 'card', amountCents: 770 }),
]), ]),
); );

View File

@@ -95,7 +95,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID, terminalId: TERMINAL_ID,
userId: USER_ID, userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }], items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }], payments: [{ methodCode: 'cash', amountCents: 3300, tenderedCents: 3300 }],
}); });
expect(sale.state).toBe('COMPLETED'); expect(sale.state).toBe('COMPLETED');
@@ -127,11 +127,11 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const body = response.json(); const body = response.json();
expect(body.state).toBe('PARTIALLY_REFUNDED'); expect(body.state).toBe('PARTIALLY_REFUNDED');
expect(body.refundedCents).toBe(1000); expect(body.refundedCents).toBe(1100);
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true); expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
expect(body.receipt.totalCents).toBe(-1000); expect(body.receipt.totalCents).toBe(-1100);
expect(body.receipt.items[0].quantity).toBe(1); expect(body.receipt.items[0].quantity).toBe(1);
expect(body.receipt.items[0].totalCents).toBe(-1000); expect(body.receipt.items[0].totalCents).toBe(-1100);
const order = await pool.query<{ state: string }>( const order = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`, `SELECT state FROM orders_orders WHERE id = $1`,
@@ -183,7 +183,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }, { kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 }, { kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
], ],
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }], payments: [{ methodCode: 'cash', amountCents: 1600, tenderedCents: 1600 }],
}); });
expect(sale.state).toBe('COMPLETED'); expect(sale.state).toBe('COMPLETED');
@@ -214,7 +214,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const body = response.json(); const body = response.json();
expect(body.state).toBe('REFUNDED'); expect(body.state).toBe('REFUNDED');
expect(body.refundedCents).toBe(1500); expect(body.refundedCents).toBe(1600);
const stock = await pool.query<{ available: number }>( const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`, `SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
@@ -238,7 +238,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID, terminalId: TERMINAL_ID,
userId: USER_ID, userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }], items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }], payments: [{ methodCode: 'cash', amountCents: 1100, tenderedCents: 1100 }],
}); });
const items = await pool.query<{ id: string }>( const items = await pool.query<{ id: string }>(
@@ -271,7 +271,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID, terminalId: TERMINAL_ID,
userId: USER_ID, userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }], items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }], payments: [{ methodCode: 'cash', amountCents: 2200, tenderedCents: 2200 }],
}); });
const items = await pool.query<{ id: string }>( const items = await pool.query<{ id: string }>(

View File

@@ -43,12 +43,12 @@ export async function registerAdminStatsRoutes(
) )
.then((r) => parseInt(r.rows[0]?.count ?? '0', 10)), .then((r) => parseInt(r.rows[0]?.count ?? '0', 10)),
// Revenue today (sum of PAID, PROCESSING, SHIPPED, DELIVERED orders) // Revenue today (sum of PAID, PROCESSING, SHIPPED, DELIVERED, COMPLETED orders)
pool pool
.query<{ total: string }>( .query<{ total: string }>(
`SELECT COALESCE(SUM(total_cents), 0)::text AS total FROM orders_orders `SELECT COALESCE(SUM(total_cents), 0)::text AS total FROM orders_orders
WHERE created_at >= CURRENT_DATE WHERE created_at >= CURRENT_DATE
AND state IN ('PAID','PROCESSING','SHIPPED','DELIVERED')`, AND state IN ('PAID','PROCESSING','SHIPPED','DELIVERED','COMPLETED')`,
) )
.then((r) => parseInt(r.rows[0]?.total ?? '0', 10)), .then((r) => parseInt(r.rows[0]?.total ?? '0', 10)),

View File

@@ -15,7 +15,7 @@ export function notificationsRoutes(fastify: FastifyInstance, pool: Pool) {
AND updated_at < NOW() - INTERVAL '24 hours' AND updated_at < NOW() - INTERVAL '24 hours'
) AS shipped_24h, ) AS shipped_24h,
COUNT(*) FILTER ( COUNT(*) FILTER (
WHERE payment_status = 'AWAITING' WHERE state = 'AWAITING_PAYMENT'
AND state = 'PENDING' AND state = 'PENDING'
) AS awaiting_payment ) AS awaiting_payment
FROM pos_orders FROM pos_orders

View File

@@ -166,7 +166,7 @@ export async function registerBackofficeRoutes(
SELECT SELECT
COUNT(*) FILTER (WHERE state = 'PENDING') AS pending, COUNT(*) FILTER (WHERE state = 'PENDING') AS pending,
COUNT(*) FILTER (WHERE state = 'SHIPPED' AND updated_at < NOW() - INTERVAL '24 hours') AS shipped_24h, COUNT(*) FILTER (WHERE state = 'SHIPPED' AND updated_at < NOW() - INTERVAL '24 hours') AS shipped_24h,
COUNT(*) FILTER (WHERE payment_status = 'AWAITING' AND state = 'PENDING') AS awaiting_payment COUNT(*) FILTER (WHERE state = 'AWAITING_PAYMENT') AS awaiting_payment
FROM pos_orders FROM pos_orders
WHERE deleted_at IS NULL AND created_at > NOW() - INTERVAL '30 days' WHERE deleted_at IS NULL AND created_at > NOW() - INTERVAL '30 days'
`); `);

View File

@@ -19,8 +19,16 @@ export class CartService {
async addItem(userId: string, input: CartItemInput): Promise<CartView> { async addItem(userId: string, input: CartItemInput): Promise<CartView> {
ensurePositiveQuantity(input.quantity); ensurePositiveQuantity(input.quantity);
// Keep unavailable lines visible in the cart so the customer can remove or // F-138: stock cap is enforced on every cart mutation. The previous
// replace them; checkout remains the authoritative stock gate. // 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)); return this.toView(await this.carts.addItem(userId, input));
} }

View File

@@ -0,0 +1,633 @@
import type { FastifyInstance, FastifyReply, FastifyRequest, FastifySchema } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { ClubService } from '../application/club-service.js';
import type { ClubMember, ClubMemberWithUser, ClubRecoveryCode, ClubSettings, ClubTransaction } from '../domain/club.js';
import {
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubDisabledError,
ClubInsufficientBalanceError,
ClubMemberNotFoundError,
ClubRecoveryCodeInvalidError,
ClubRecoveryCodesDisabledError,
InvalidClubTransactionError,
} from '../domain/errors.js';
import { PgClubRepository } from '../infrastructure/pg-club-repository.js';
export const CLUB_DEVICE_COOKIE_NAME = 'mdv_club';
const CLUB_DEVICE_COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365;
export interface ClubRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const adminSettingsSchema = z
.object({
clubEnabled: z.boolean().optional(),
cashbackPercentage: z.number().min(0).max(100).optional(),
allowAnonymousMembers: z.boolean().optional(),
allowRecoveryCodes: z.boolean().optional(),
minimumRedeemAmountCents: z.number().int().min(0).optional(),
})
.strip();
const movementQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export async function registerClubRoutes(
app: FastifyInstance,
deps: ClubRoutesDeps,
): Promise<void> {
const clubs = new ClubService(new PgClubRepository(deps.pool));
app.get(
'/club/config',
{
schema: {
tags: ['Club'],
summary: 'Get public Club configuration',
response: { 200: { type: 'object' } },
} as FastifySchema,
},
async (_request, reply) => {
return reply.send(serializeSettings(await clubs.getPublicConfig()));
},
);
app.post(
'/club/join',
{
schema: {
tags: ['Club'],
summary: 'Create or reuse anonymous Club member for the current device',
response: { 201: { type: 'object' }, 200: { type: 'object' }, 409: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
try {
const result = await clubs.joinAnonymous(deviceTokenFromRequest(request));
setClubDeviceCookie(reply, result.deviceToken, isSecureRequest(request));
const config = await clubs.getPublicConfig();
return reply.code(result.created ? 201 : 200).send({
created: result.created,
deviceToken: result.deviceToken,
member: serializeMember(result.member),
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/club/me',
{
schema: {
tags: ['Club'],
summary: 'Get current Club member by device token or linked account',
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
try {
const member = await clubs.getMemberOrThrow({
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
});
const config = await clubs.getPublicConfig();
return reply.send({ member: serializeMember(member), config: serializeSettings(config) });
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/club/movements',
{
schema: {
tags: ['Club'],
summary: 'List Club ledger movements for current member',
querystring: {
type: 'object',
properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } },
},
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
const { limit } = parseJson(movementQuerySchema, request.query ?? {});
try {
const { member, items } = await clubs.listMovements(
{
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
},
limit,
);
return reply.send({
member: serializeMember(member),
items: items.map(serializeTransaction),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── Link anonymous member to registered user account ──────────────────────
// Called by the frontend after login/register to link the device's anonymous
// member to the newly authenticated user account.
app.patch(
'/club/me',
{
schema: {
tags: ['Club'],
summary: 'Link current anonymous Club member to the authenticated user account',
response: { 200: { type: 'object' }, 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request); // requires session
try {
const result = await clubs.linkMemberToUser({
userId: user.id,
deviceToken: deviceTokenFromRequest(request),
});
const config = await clubs.getPublicConfig();
return reply.send({
member: serializeMember(result.member),
linked: result.linked,
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── Merge two Club memberships ───────────────────────────────────────────────
app.post(
'/club/merge',
{
schema: {
tags: ['Club'],
summary: 'Merge an anonymous Club member into the authenticated user\'s member',
body: {
type: 'object',
required: ['anonymousMemberId'],
properties: {
anonymousMemberId: { type: 'string', format: 'uuid' },
},
},
response: { 200: { type: 'object' }, 400: errorSchema, 401: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request); // requires session
const body = request.body as { anonymousMemberId?: string };
if (!body?.anonymousMemberId) {
throw new AppError(400, 'CLUB_INVALID_MERGE', 'anonymousMemberId es obligatorio');
}
try {
// The user's registered member is the primary
const userMember = await clubs.getMemberOrThrow({
userId: user.id,
deviceToken: null,
});
const result = await clubs.mergeMembers(userMember.id, body.anonymousMemberId);
const config = await clubs.getPublicConfig();
return reply.send({
member: serializeMember(result.primary),
mergedId: result.merged.id,
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── Recovery codes ───────────────────────────────────────────────────────────
// Generate new recovery codes (plaintext codes returned — show once only)
app.post(
'/club/recovery-codes/generate',
{
schema: {
tags: ['Club'],
summary: 'Generate new recovery codes for current member',
body: {
type: 'object',
properties: { count: { type: 'integer', minimum: 1, maximum: 10, default: 3 } },
},
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
const body = request.body as { count?: number } | null;
const count = body?.count ?? 3;
try {
const result = await clubs.generateRecoveryCodes(
{
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
},
count,
);
const config = await clubs.getPublicConfig();
return reply.send({
member: serializeMember(result.member),
codes: result.codes, // plaintext — show once
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// List active (unused, not expired) recovery codes
app.get(
'/club/recovery-codes',
{
schema: {
tags: ['Club'],
summary: 'List active recovery codes for current member (fingerprints only)',
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticateOptional(deps.authenticate, request);
try {
const result = await clubs.listRecoveryCodes({
userId: user?.role === 'customer' ? user.id : null,
deviceToken: deviceTokenFromRequest(request),
});
return reply.send({
member: serializeMember(result.member),
codes: result.codes.map((code: ClubRecoveryCode) => ({
id: code.id,
fingerprint: code.codeFingerprint,
expiresAt: code.expiresAt.toISOString(),
createdAt: code.createdAt.toISOString(),
})),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── Recovery: use a code to link a new device ───────────────────────────────
app.post(
'/club/recover',
{
schema: {
tags: ['Club'],
summary: 'Recover Club membership using a recovery code and a new device token',
body: {
type: 'object',
required: ['code', 'newDeviceToken'],
properties: {
code: { type: 'string', minLength: 1 },
newDeviceToken: { type: 'string', minLength: 1 },
},
},
response: { 200: { type: 'object' }, 400: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const body = request.body as { code?: string; newDeviceToken?: string };
if (!body?.code || !body?.newDeviceToken) {
throw new AppError(400, 'CLUB_INVALID_RECOVER', 'code y newDeviceToken son obligatorios');
}
try {
const result = await clubs.recoverByCode(body.code, body.newDeviceToken);
setClubDeviceCookie(reply, result.deviceToken, isSecureRequest(request));
const config = await clubs.getPublicConfig();
return reply.send({
member: serializeMember(result.member),
deviceToken: result.deviceToken,
config: serializeSettings(config),
});
} catch (error) {
throw mapClubError(error);
}
},
);
// ── POS / public resolve by member code ───────────────────────────────────
app.get(
'/club/resolve',
{
schema: {
tags: ['Club'],
summary: 'Resolve Club member by member code (for TPV)',
querystring: {
type: 'object',
required: ['memberCode'],
properties: { memberCode: { type: 'string', minLength: 1 } },
},
response: { 200: { type: 'object' }, 404: errorSchema, 409: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const query = request.query as { memberCode?: string };
const code = query.memberCode;
if (!code || typeof code !== 'string' || !code.trim()) {
throw new AppError(400, 'CLUB_INVALID_CODE', 'memberCode es obligatorio');
}
try {
const member = await clubs.resolveByCode(code.trim());
if (!member) throw new ClubMemberNotFoundError();
const config = await clubs.getPublicConfig();
return reply.send({ member: serializeMember(member), config: serializeSettings(config) });
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/admin/club/settings',
{
schema: {
tags: ['Club Admin'],
summary: 'Get Club settings (admin)',
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
return reply.send(serializeSettings(await clubs.getAdminSettings()));
},
);
app.patch(
'/admin/club/settings',
{
schema: {
tags: ['Club Admin'],
summary: 'Update Club settings (admin)',
body: { type: 'object' },
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(adminSettingsSchema, request.body ?? {});
try {
const updatedBy = await resolveUpdatedBy(deps.pool, user);
const settings = await clubs.updateSettings(
{
enabled: input.clubEnabled,
cashbackBps:
input.cashbackPercentage === undefined
? undefined
: Math.round(input.cashbackPercentage * 100),
allowAnonymousMembers: input.allowAnonymousMembers,
allowRecoveryCodes: input.allowRecoveryCodes,
minimumRedeemAmountCents: input.minimumRedeemAmountCents,
},
updatedBy,
);
return reply.send(serializeSettings(settings));
} catch (error) {
throw mapClubError(error);
}
},
);
// ── Admin member management ──────────────────────────────────────────────────
app.get(
'/admin/club/members',
{
schema: {
tags: ['Club Admin'],
summary: 'List Club members (admin)',
querystring: {
type: 'object',
properties: {
q: { type: 'string' },
status: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 },
},
},
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const query = request.query as { q?: string; status?: string; limit?: number; offset?: number };
const result = await clubs.listMembers({
q: query.q,
status: query.status,
limit: query.limit ?? 20,
offset: query.offset ?? 0,
});
return reply.send({
items: result.items.map((m: ClubMemberWithUser) => serializeMemberWithUser(m)),
total: result.total,
limit: result.limit,
offset: result.offset,
});
},
);
app.get(
'/admin/club/members/:memberId',
{
schema: {
tags: ['Club Admin'],
summary: 'Get Club member detail (admin)',
params: {
type: 'object',
required: ['memberId'],
properties: { memberId: { type: 'string', format: 'uuid' } },
},
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const params = request.params as { memberId?: string };
try {
const member = await clubs.getMemberById(params.memberId!);
return reply.send({ member: serializeMemberWithUser(member) });
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/admin/club/members/:memberId/movements',
{
schema: {
tags: ['Club Admin'],
summary: 'List Club member movements (admin)',
params: {
type: 'object',
required: ['memberId'],
properties: { memberId: { type: 'string', format: 'uuid' } },
},
querystring: {
type: 'object',
properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } },
},
response: { 200: { type: 'object' }, 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const params = request.params as { memberId?: string };
const query = request.query as { limit?: number };
try {
const member = await clubs.getMemberById(params.memberId!);
const { items } = await clubs.listMemberTransactions(member.id, query.limit ?? 50);
return reply.send({
member: serializeMemberWithUser(member),
items: items.map(serializeTransaction),
});
} catch (error) {
throw mapClubError(error);
}
},
);
}
async function authenticateOptional(
authenticate: Authenticate,
request: FastifyRequest,
): Promise<CurrentUser | null> {
try {
return await authenticate(request);
} catch (error) {
if (error instanceof AppError && error.statusCode === 401) return null;
throw error;
}
}
function deviceTokenFromRequest(request: FastifyRequest): string | null {
const header = request.headers['x-club-device-token'];
const token = Array.isArray(header) ? header[0] : header;
if (typeof token === 'string' && token.trim()) return token.trim();
const cookieToken = request.cookies?.[CLUB_DEVICE_COOKIE_NAME];
return typeof cookieToken === 'string' && cookieToken.trim() ? cookieToken.trim() : null;
}
function isSecureRequest(request: FastifyRequest): boolean {
const forwardedProto = request.headers['x-forwarded-proto'];
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
return request.protocol === 'https' || proto === 'https';
}
function setClubDeviceCookie(reply: FastifyReply, token: string, secure: boolean): void {
void reply.setCookie(CLUB_DEVICE_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure,
maxAge: CLUB_DEVICE_COOKIE_MAX_AGE_S,
});
}
async function resolveUpdatedBy(pool: pg.Pool, user: CurrentUser): Promise<string | null> {
const identityUser = await pool.query<{ id: string }>(`SELECT id FROM identity_users WHERE id = $1`, [
user.id,
]);
return identityUser.rowCount ? user.id : null;
}
function mapClubError(error: unknown): Error {
if (error instanceof ClubDisabledError) {
return new AppError(409, 'CLUB_DISABLED', error.message);
}
if (error instanceof ClubAnonymousJoinDisabledError) {
return new AppError(403, 'CLUB_ANONYMOUS_DISABLED', error.message);
}
if (error instanceof ClubDeviceTokenRequiredError) {
return new AppError(401, 'CLUB_DEVICE_REQUIRED', error.message);
}
if (error instanceof ClubMemberNotFoundError) {
return new AppError(404, 'CLUB_MEMBER_NOT_FOUND', error.message);
}
if (error instanceof ClubInsufficientBalanceError) {
return new AppError(409, 'CLUB_INSUFFICIENT_BALANCE', error.message);
}
if (error instanceof InvalidClubTransactionError) {
return new AppError(400, 'CLUB_INVALID_TRANSACTION', error.message);
}
if (error instanceof ClubRecoveryCodeInvalidError) {
return new AppError(400, 'CLUB_RECOVERY_CODE_INVALID', error.message);
}
if (error instanceof ClubRecoveryCodesDisabledError) {
return new AppError(403, 'CLUB_RECOVERY_CODES_DISABLED', error.message);
}
return error instanceof Error ? error : new Error('Unknown club error');
}
function serializeSettings(settings: ClubSettings) {
return {
clubEnabled: settings.enabled,
cashbackBps: settings.cashbackBps,
cashbackPercentage: settings.cashbackBps / 100,
allowAnonymousMembers: settings.allowAnonymousMembers,
allowRecoveryCodes: settings.allowRecoveryCodes,
minimumRedeemAmountCents: settings.minimumRedeemAmountCents,
};
}
function serializeMember(member: ClubMember) {
return {
id: member.id,
userId: member.userId,
memberCode: member.memberCode,
status: member.status,
tierCode: member.tierCode,
currentBalanceCents: member.currentBalanceCents,
isAnonymous: member.userId === null,
createdAt: member.createdAt.toISOString(),
updatedAt: member.updatedAt.toISOString(),
};
}
function serializeMemberWithUser(member: ClubMemberWithUser) {
return {
...serializeMember(member),
userEmail: member.userEmail,
};
}
function serializeTransaction(transaction: ClubTransaction) {
return {
id: transaction.id,
memberId: transaction.memberId,
saleId: transaction.saleId,
storeId: transaction.storeId,
type: transaction.type,
amountCents: transaction.amountCents,
balanceDeltaCents: transaction.balanceDeltaCents,
idempotencyKey: transaction.idempotencyKey,
metadata: transaction.metadata,
createdAt: transaction.createdAt.toISOString(),
};
}

View File

@@ -0,0 +1,234 @@
import type {
ClubMember,
ClubMemberWithUser,
ClubResolveInput,
ClubSettings,
ClubTransaction,
GenerateRecoveryCodesResult,
JoinClubResult,
LinkMemberResult,
MergeMembersResult,
PaginatedClubMembers,
RecoverByCodeResult,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from '../domain/club.js';
import {
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubDisabledError,
ClubMemberNotFoundError,
ClubRecoveryCodeInvalidError,
ClubRecoveryCodesDisabledError,
InvalidClubTransactionError,
} from '../domain/errors.js';
import type { ClubRepository } from '../domain/ports.js';
import { generateDeviceToken, hashDeviceToken } from '../infrastructure/device-token.js';
import { generateMemberCode } from '../infrastructure/member-code.js';
export class ClubService {
constructor(private readonly clubs: ClubRepository) {}
getPublicConfig(): Promise<ClubSettings> {
return this.clubs.getSettings();
}
getAdminSettings(): Promise<ClubSettings> {
return this.clubs.getSettings();
}
async updateSettings(
input: UpdateClubSettingsCommand,
updatedBy: string | null,
): Promise<ClubSettings> {
if (input.cashbackBps !== undefined && (!Number.isInteger(input.cashbackBps) || input.cashbackBps < 0)) {
throw new InvalidClubTransactionError('El cashback del Club debe ser un entero no negativo');
}
if (
input.minimumRedeemAmountCents !== undefined &&
(!Number.isInteger(input.minimumRedeemAmountCents) || input.minimumRedeemAmountCents < 0)
) {
throw new InvalidClubTransactionError(
'El mínimo de canje del Club debe ser un entero no negativo',
);
}
return this.clubs.updateSettings(input, updatedBy);
}
async joinAnonymous(existingDeviceToken?: string | null): Promise<JoinClubResult> {
const normalizedExisting = normalizeToken(existingDeviceToken);
if (normalizedExisting) {
const existing = await this.clubs.findMemberByDeviceTokenHash(
hashDeviceToken(normalizedExisting),
true,
);
if (existing) {
return { member: existing, deviceToken: normalizedExisting, created: false };
}
}
const settings = await this.clubs.getSettings();
if (!settings.enabled) throw new ClubDisabledError();
if (!settings.allowAnonymousMembers) throw new ClubAnonymousJoinDisabledError();
const deviceToken = generateDeviceToken();
const deviceTokenHash = hashDeviceToken(deviceToken);
for (let attempt = 0; attempt < 10; attempt += 1) {
const member = await this.clubs.createMemberWithDevice({
memberCode: generateMemberCode(),
deviceTokenHash,
});
if (member) return { member, deviceToken, created: true };
}
throw new Error('No se pudo generar un código único para el Club');
}
resolveMember(input: ClubResolveInput): Promise<ClubMember | null> {
return this.clubs.resolveMember({
...input,
deviceTokenHash: input.deviceToken ? hashDeviceToken(input.deviceToken) : null,
});
}
async resolveByCode(code: string): Promise<ClubMember | null> {
if (!code || typeof code !== 'string') return null;
const normalized = code.trim().toUpperCase();
if (!normalized) return null;
return this.clubs.findMemberByCode(normalized);
}
async getMemberOrThrow(input: ClubResolveInput): Promise<ClubMember> {
const normalized = {
userId: input.userId ?? null,
deviceToken: normalizeToken(input.deviceToken),
};
if (!normalized.userId && !normalized.deviceToken) {
throw new ClubDeviceTokenRequiredError();
}
const member = await this.resolveMember(normalized);
if (!member) throw new ClubMemberNotFoundError();
return member;
}
async listMovements(input: ClubResolveInput, limit = 50) {
const safeLimit = Math.min(Math.max(limit, 1), 200);
const member = await this.getMemberOrThrow(input);
const items = await this.clubs.listTransactions(member.id, safeLimit);
return { member, items };
}
async generateRecoveryCodes(input: ClubResolveInput, count = 3): Promise<GenerateRecoveryCodesResult> {
const member = await this.getMemberOrThrow(input);
const settings = await this.clubs.getSettings();
if (!settings.allowRecoveryCodes) throw new ClubRecoveryCodesDisabledError();
const safeCount = Math.min(Math.max(count, 1), 10);
const generated = await this.clubs.generateRecoveryCodes(member.id, safeCount, 30);
return {
member,
codes: generated.map((g) => g.code),
};
}
async listRecoveryCodes(input: ClubResolveInput) {
const member = await this.getMemberOrThrow(input);
const settings = await this.clubs.getSettings();
if (!settings.allowRecoveryCodes) throw new ClubRecoveryCodesDisabledError();
const codes = await this.clubs.listActiveRecoveryCodes(member.id);
return { member, codes };
}
async linkMemberToUser(input: ClubResolveInput): Promise<LinkMemberResult> {
if (!input.userId) throw new ClubDeviceTokenRequiredError();
const member = await this.getMemberOrThrow(input);
return this.clubs.linkMemberToUser(member.id, input.userId);
}
async listMembers(opts: { q?: string; status?: string; limit: number; offset: number }): Promise<PaginatedClubMembers> {
return this.clubs.listMembers({
q: opts.q?.trim() || undefined,
status: opts.status || undefined,
limit: Math.min(Math.max(opts.limit, 1), 100),
offset: Math.max(opts.offset, 0),
});
}
async getMemberById(id: string): Promise<ClubMemberWithUser> {
const member = await this.clubs.findMemberById(id);
if (!member) throw new ClubMemberNotFoundError();
return member;
}
async listMemberTransactions(memberId: string, limit = 50): Promise<{ items: ClubTransaction[] }> {
const safeLimit = Math.min(Math.max(limit, 1), 200);
const items = await this.clubs.listTransactions(memberId, safeLimit);
return { items };
}
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
if (primaryMemberId === mergedMemberId) {
throw new InvalidClubTransactionError('No se puede fusionar un socio consigo mismo');
}
return this.clubs.mergeMembers(primaryMemberId, mergedMemberId);
}
async recoverByCode(code: string, newDeviceToken: string): Promise<RecoverByCodeResult> {
if (!code || typeof code !== 'string' || !code.trim()) {
throw new ClubRecoveryCodeInvalidError('Código de recuperación obligatorio');
}
if (!newDeviceToken || typeof newDeviceToken !== 'string' || !newDeviceToken.trim()) {
throw new ClubDeviceTokenRequiredError();
}
const settings = await this.clubs.getSettings();
if (!settings.allowRecoveryCodes) throw new ClubRecoveryCodesDisabledError();
const normalizedCode = code.trim().toUpperCase();
const fingerprint = hashDeviceToken(normalizedCode).slice(0, 16);
const newDeviceTokenHash = hashDeviceToken(newDeviceToken.trim());
let result: { memberId: string; fingerprint: string };
try {
result = await this.clubs.consumeRecoveryCode(fingerprint, newDeviceTokenHash);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
throw new ClubRecoveryCodeInvalidError();
}
throw error;
}
const member = await this.clubs.findMemberByUserId(result.memberId);
if (!member) {
const byDevice = await this.clubs.findMemberByDeviceTokenHash(result.memberId, false);
if (!byDevice) throw new ClubMemberNotFoundError();
return { member: byDevice, deviceToken: newDeviceToken.trim(), usedCodeFingerprint: result.fingerprint };
}
return { member, deviceToken: newDeviceToken.trim(), usedCodeFingerprint: result.fingerprint };
}
async recordTransaction(
input: RecordClubTransactionCommand,
): Promise<RecordClubTransactionResult> {
if (!Number.isInteger(input.amountCents) || input.amountCents < 0) {
throw new InvalidClubTransactionError('El importe base del movimiento Club no es válido');
}
if (!Number.isInteger(input.balanceDeltaCents) || input.balanceDeltaCents === 0) {
throw new InvalidClubTransactionError('El delta de saldo Club no es válido');
}
return this.clubs.recordTransaction({
...input,
idempotencyKey: input.idempotencyKey ?? null,
metadata: input.metadata ?? {},
saleId: input.saleId ?? null,
storeId: input.storeId ?? null,
});
}
}
function normalizeToken(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = value.trim();
return normalized ? normalized : null;
}

View File

@@ -0,0 +1,119 @@
export type ClubMemberStatus = 'active' | 'blocked' | 'merged';
export type ClubTransactionType = 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
export interface ClubMember {
id: string;
userId: string | null;
memberCode: string;
status: ClubMemberStatus;
tierCode: string;
currentBalanceCents: number;
createdAt: Date;
updatedAt: Date;
}
export interface ClubTransaction {
id: string;
memberId: string;
saleId: string | null;
storeId: string | null;
type: ClubTransactionType;
amountCents: number;
balanceDeltaCents: number;
idempotencyKey: string | null;
metadata: Record<string, unknown>;
createdAt: Date;
}
export interface ClubSettings {
enabled: boolean;
cashbackBps: number;
allowAnonymousMembers: boolean;
allowRecoveryCodes: boolean;
minimumRedeemAmountCents: number;
}
export const DEFAULT_CLUB_SETTINGS: ClubSettings = {
enabled: true,
cashbackBps: 200,
allowAnonymousMembers: true,
allowRecoveryCodes: true,
minimumRedeemAmountCents: 500,
};
export interface ClubResolveInput {
userId?: string | null;
deviceToken?: string | null;
}
export interface UpdateClubSettingsCommand {
enabled?: boolean;
cashbackBps?: number;
allowAnonymousMembers?: boolean;
allowRecoveryCodes?: boolean;
minimumRedeemAmountCents?: number;
}
export interface RecordClubTransactionCommand {
memberId: string;
saleId?: string | null;
storeId?: string | null;
type: ClubTransactionType;
amountCents: number;
balanceDeltaCents: number;
idempotencyKey?: string | null;
metadata?: Record<string, unknown>;
}
export interface JoinClubResult {
member: ClubMember;
deviceToken: string;
created: boolean;
}
export interface RecordClubTransactionResult {
member: ClubMember;
transaction: ClubTransaction;
created: boolean;
}
export interface ClubRecoveryCode {
id: string;
memberId: string;
codeFingerprint: string;
usedAt: Date | null;
expiresAt: Date;
createdAt: Date;
}
export interface GenerateRecoveryCodesResult {
member: ClubMember;
codes: string[]; // plaintext — only returned on generation, never stored
}
export interface RecoverByCodeResult {
member: ClubMember;
deviceToken: string;
usedCodeFingerprint: string;
}
export interface LinkMemberResult {
member: ClubMember;
linked: boolean; // true if newly linked, false if already had userId
}
export interface MergeMembersResult {
primary: ClubMember; // the member kept (registered one)
merged: ClubMember; // the member archived (anonymous one)
}
export interface ClubMemberWithUser extends ClubMember {
userEmail: string | null;
}
export interface PaginatedClubMembers {
items: ClubMemberWithUser[];
total: number;
limit: number;
offset: number;
}

View File

@@ -0,0 +1,69 @@
export class ClubDisabledError extends Error {
constructor() {
super('Club de Clientes no está disponible');
this.name = 'ClubDisabledError';
}
}
export class ClubAnonymousJoinDisabledError extends Error {
constructor() {
super('El alta anónima del Club está desactivada');
this.name = 'ClubAnonymousJoinDisabledError';
}
}
export class ClubDeviceTokenRequiredError extends Error {
constructor() {
super('Se requiere un token de dispositivo Club');
this.name = 'ClubDeviceTokenRequiredError';
}
}
export class ClubMemberNotFoundError extends Error {
constructor() {
super('No se encontró el socio Club');
this.name = 'ClubMemberNotFoundError';
}
}
export class ClubInsufficientBalanceError extends Error {
constructor() {
super('Saldo Club insuficiente');
this.name = 'ClubInsufficientBalanceError';
}
}
export class InvalidClubTransactionError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidClubTransactionError';
}
}
export class ClubRecoveryCodeInvalidError extends Error {
constructor(message = 'Código de recuperación inválido o caducado') {
super(message);
this.name = 'ClubRecoveryCodeInvalidError';
}
}
export class ClubRecoveryCodesDisabledError extends Error {
constructor() {
super('Los códigos de recuperación del Club están desactivados');
this.name = 'ClubRecoveryCodesDisabledError';
}
}
export class ClubMemberAlreadyLinkedError extends Error {
constructor() {
super('Este dispositivo ya está vinculado a otra cuenta');
this.name = 'ClubMemberAlreadyLinkedError';
}
}
export class ClubMergeConflictError extends Error {
constructor(message = 'No se puede fusionar las cuentas del Club') {
super(message);
this.name = 'ClubMergeConflictError';
}
}

View File

@@ -0,0 +1,45 @@
import type {
ClubMember,
ClubMemberWithUser,
ClubRecoveryCode,
ClubResolveInput,
ClubSettings,
ClubTransaction,
LinkMemberResult,
MergeMembersResult,
PaginatedClubMembers,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from './club.js';
export interface CreateClubMemberCommand {
memberCode: string;
deviceTokenHash: string;
userId?: string | null;
}
export interface ClubRepository {
getSettings(): Promise<ClubSettings>;
updateSettings(input: UpdateClubSettingsCommand, updatedBy: string | null): Promise<ClubSettings>;
createMemberWithDevice(input: CreateClubMemberCommand): Promise<ClubMember | null>;
findMemberByUserId(userId: string): Promise<ClubMember | null>;
findMemberByDeviceTokenHash(hash: string, touch?: boolean): Promise<ClubMember | null>;
findMemberByCode(code: string): Promise<ClubMember | null>;
resolveMember(input: ClubResolveInput & { deviceTokenHash?: string | null }): Promise<ClubMember | null>;
listTransactions(memberId: string, limit: number): Promise<ClubTransaction[]>;
recordTransaction(input: RecordClubTransactionCommand): Promise<RecordClubTransactionResult>;
// Recovery codes
generateRecoveryCodes(memberId: string, count: number, expiresInDays: number): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]>;
listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]>;
consumeRecoveryCode(fingerprint: string, newDeviceTokenHash: string): Promise<{ memberId: string; fingerprint: string }>;
// Member linking and merging
linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult>;
mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult>;
// Admin
listMembers(opts: { q?: string; status?: string; limit: number; offset: number }): Promise<PaginatedClubMembers>;
findMemberById(id: string): Promise<ClubMemberWithUser | null>;
}

View File

@@ -0,0 +1,32 @@
import type pg from 'pg';
import { ClubService } from './application/club-service.js';
import { PgClubRepository } from './infrastructure/pg-club-repository.js';
export { registerClubRoutes, type ClubRoutesDeps, CLUB_DEVICE_COOKIE_NAME } from './api/club.routes.js';
export { ClubService } from './application/club-service.js';
export type {
ClubMember,
ClubMemberStatus,
ClubSettings,
ClubTransaction,
ClubTransactionType,
JoinClubResult,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from './domain/club.js';
export {
ClubDisabledError,
ClubAnonymousJoinDisabledError,
ClubDeviceTokenRequiredError,
ClubMemberNotFoundError,
ClubInsufficientBalanceError,
InvalidClubTransactionError,
} from './domain/errors.js';
export type { ClubRepository } from './domain/ports.js';
export { generateDeviceToken, hashDeviceToken } from './infrastructure/device-token.js';
export { generateMemberCode } from './infrastructure/member-code.js';
export function createClubService(pool: pg.Pool): ClubService {
return new ClubService(new PgClubRepository(pool));
}

View File

@@ -0,0 +1,9 @@
import { createHash, randomBytes } from 'node:crypto';
export function generateDeviceToken(): string {
return randomBytes(48).toString('base64url');
}
export function hashDeviceToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'node:crypto';
export function generateMemberCode(): string {
return `MDV-${randomBytes(4).toString('hex').toUpperCase()}`;
}

View File

@@ -0,0 +1,666 @@
import { createHash, randomBytes } from 'node:crypto';
import type pg from 'pg';
import type {
ClubMember,
ClubMemberWithUser,
ClubRecoveryCode,
ClubResolveInput,
ClubSettings,
ClubTransaction,
LinkMemberResult,
MergeMembersResult,
PaginatedClubMembers,
RecordClubTransactionCommand,
RecordClubTransactionResult,
UpdateClubSettingsCommand,
} from '../domain/club.js';
import { DEFAULT_CLUB_SETTINGS } from '../domain/club.js';
import { ClubInsufficientBalanceError } from '../domain/errors.js';
import type { ClubRepository, CreateClubMemberCommand } from '../domain/ports.js';
import { hashDeviceToken } from './device-token.js';
interface SettingRow {
key: string;
value: string;
}
interface MemberRow {
id: string;
user_id: string | null;
member_code: string;
status: 'active' | 'blocked' | 'merged';
tier_code: string;
current_balance_cents: number;
created_at: Date;
updated_at: Date;
}
interface TransactionRow {
id: string;
member_id: string;
sale_id: string | null;
store_id: string | null;
type: 'earn' | 'redeem' | 'refund' | 'bonus' | 'adjustment';
amount_cents: number;
balance_delta_cents: number;
idempotency_key: string | null;
metadata: Record<string, unknown> | null;
created_at: Date;
}
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
const CLUB_SETTING_KEYS = {
enabled: 'club_enabled',
cashbackBps: 'club_cashback_bps',
allowAnonymousMembers: 'club_allow_anonymous_members',
allowRecoveryCodes: 'club_allow_recovery_codes',
minimumRedeemAmountCents: 'club_minimum_redeem_cents',
} as const;
export class PgClubRepository implements ClubRepository {
constructor(private readonly pool: pg.Pool) {}
async getSettings(): Promise<ClubSettings> {
const result = await this.pool.query<SettingRow>(
`SELECT key, value FROM store_settings WHERE key = ANY($1::text[])`,
[Object.values(CLUB_SETTING_KEYS)],
);
return parseSettings(result.rows);
}
async updateSettings(
input: UpdateClubSettingsCommand,
updatedBy: string | null,
): Promise<ClubSettings> {
const updates: Array<[string, string]> = [];
if (input.enabled !== undefined) {
updates.push([CLUB_SETTING_KEYS.enabled, String(input.enabled)]);
}
if (input.cashbackBps !== undefined) {
updates.push([CLUB_SETTING_KEYS.cashbackBps, String(input.cashbackBps)]);
}
if (input.allowAnonymousMembers !== undefined) {
updates.push([
CLUB_SETTING_KEYS.allowAnonymousMembers,
String(input.allowAnonymousMembers),
]);
}
if (input.allowRecoveryCodes !== undefined) {
updates.push([CLUB_SETTING_KEYS.allowRecoveryCodes, String(input.allowRecoveryCodes)]);
}
if (input.minimumRedeemAmountCents !== undefined) {
updates.push([
CLUB_SETTING_KEYS.minimumRedeemAmountCents,
String(input.minimumRedeemAmountCents),
]);
}
for (const [key, value] of updates) {
await this.pool.query(
`INSERT INTO store_settings (key, value, updated_by) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by`,
[key, value, updatedBy],
);
}
return this.getSettings();
}
async createMemberWithDevice(input: CreateClubMemberCommand): Promise<ClubMember | null> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const memberResult = await client.query<MemberRow>(
`INSERT INTO club_members (user_id, member_code, status, tier_code, current_balance_cents)
VALUES ($1, $2, 'active', 'base', 0)
ON CONFLICT (member_code) DO NOTHING
RETURNING *`,
[input.userId ?? null, input.memberCode],
);
const row = memberResult.rows[0];
if (!row) {
await client.query('ROLLBACK');
return null;
}
await client.query(
`INSERT INTO club_devices (member_id, device_token_hash)
VALUES ($1, $2)`,
[row.id, input.deviceTokenHash],
);
await client.query('COMMIT');
return toMember(row);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async findMemberByUserId(userId: string): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT * FROM club_members
WHERE user_id = $1 AND status <> 'merged'
LIMIT 1`,
[userId],
);
return result.rows[0] ? toMember(result.rows[0]) : null;
}
async findMemberByCode(code: string): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT * FROM club_members
WHERE member_code = $1 AND status = 'active'
LIMIT 1`,
[code],
);
return result.rows[0] ? toMember(result.rows[0]) : null;
}
async findMemberByDeviceTokenHash(hash: string, touch = false): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT member.*
FROM club_devices device
JOIN club_members member ON member.id = device.member_id
WHERE device.device_token_hash = $1
AND device.revoked_at IS NULL
AND member.status <> 'merged'
LIMIT 1`,
[hash],
);
const row = result.rows[0];
if (!row) return null;
if (touch) {
await this.pool.query(
`UPDATE club_devices SET last_used_at = now()
WHERE device_token_hash = $1 AND revoked_at IS NULL`,
[hash],
);
}
return toMember(row);
}
async resolveMember(
input: ClubResolveInput & { deviceTokenHash?: string | null },
): Promise<ClubMember | null> {
if (input.userId) {
const byUser = await this.findMemberByUserId(input.userId);
if (byUser) return byUser;
}
if (input.deviceTokenHash) {
return this.findMemberByDeviceTokenHash(input.deviceTokenHash, true);
}
return null;
}
async listTransactions(memberId: string, limit: number): Promise<ClubTransaction[]> {
const result = await this.pool.query<TransactionRow>(
`SELECT * FROM club_transactions
WHERE member_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2`,
[memberId, limit],
);
return result.rows.map(toTransaction);
}
// ── Admin ──────────────────────────────────────────────────────────────────
async listMembers(
opts: { q?: string; status?: string; limit: number; offset: number },
): Promise<PaginatedClubMembers> {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
if (opts.q) {
conditions.push(
`(member_code ILIKE $${paramIdx} OR identity_users.email ILIKE $${paramIdx})`,
);
params.push(`%${opts.q}%`);
paramIdx += 1;
}
if (opts.status) {
conditions.push(`member.status = $${paramIdx}`);
params.push(opts.status);
paramIdx += 1;
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*)::int AS count
FROM club_members member
LEFT JOIN identity_users ON identity_users.id = member.user_id
${where}`,
params,
);
const total = Number(countResult.rows[0]?.count ?? 0);
const limit = Math.min(Math.max(opts.limit, 1), 100);
const offset = Math.max(opts.offset, 0);
const rows = await this.pool.query<{
id: string;
user_id: string | null;
member_code: string;
status: 'active' | 'blocked' | 'merged';
tier_code: string;
current_balance_cents: string;
created_at: Date;
updated_at: Date;
user_email: string | null;
}>(
`SELECT member.*, identity_users.email AS user_email
FROM club_members member
LEFT JOIN identity_users ON identity_users.id = member.user_id
${where}
ORDER BY member.created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
[...params, limit, offset],
);
return {
items: rows.rows.map((row) => ({
id: row.id,
userId: row.user_id,
memberCode: row.member_code,
status: row.status,
tierCode: row.tier_code,
currentBalanceCents: Number(row.current_balance_cents),
createdAt: row.created_at,
updatedAt: row.updated_at,
userEmail: row.user_email,
})),
total,
limit,
offset,
};
}
async findMemberById(id: string): Promise<ClubMemberWithUser | null> {
const result = await this.pool.query<{
id: string;
user_id: string | null;
member_code: string;
status: 'active' | 'blocked' | 'merged';
tier_code: string;
current_balance_cents: string;
created_at: Date;
updated_at: Date;
user_email: string | null;
}>(
`SELECT member.*, identity_users.email AS user_email
FROM club_members member
LEFT JOIN identity_users ON identity_users.id = member.user_id
WHERE member.id = $1`,
[id],
);
if (!result.rows[0]) return null;
const row = result.rows[0];
return {
id: row.id,
userId: row.user_id,
memberCode: row.member_code,
status: row.status,
tierCode: row.tier_code,
currentBalanceCents: Number(row.current_balance_cents),
createdAt: row.created_at,
updatedAt: row.updated_at,
userEmail: row.user_email,
};
}
// ── Member linking ─────────────────────────────────────────────────────────
async linkMemberToUser(memberId: string, userId: string): Promise<LinkMemberResult> {
const existing = await this.pool.query<MemberRow>(
`SELECT * FROM club_members WHERE user_id = $1 AND status <> 'merged' LIMIT 1`,
[userId],
);
if (existing.rows[0]) {
// User already has a member — return it, don't link the device member
return { member: toMember(existing.rows[0]), linked: false };
}
const result = await this.pool.query<MemberRow>(
`UPDATE club_members SET user_id = $2, updated_at = now()
WHERE id = $1 AND user_id IS NULL
RETURNING *`,
[memberId, userId],
);
if (!result.rows[0]) {
// Member not found or already has a userId — treat as already linked
const current = await this.pool.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1`,
[memberId],
);
if (!current.rows[0]) throw new Error(`club_members row not found: ${memberId}`);
return { member: toMember(current.rows[0]), linked: false };
}
return { member: toMember(result.rows[0]), linked: true };
}
async mergeMembers(primaryMemberId: string, mergedMemberId: string): Promise<MergeMembersResult> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const primary = await client.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1 AND status = 'active' FOR UPDATE`,
[primaryMemberId],
);
const merged = await client.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1 AND status = 'active' FOR UPDATE`,
[mergedMemberId],
);
if (!primary.rows[0] || !merged.rows[0]) {
await client.query('ROLLBACK');
throw new Error('Uno de los socios no fue encontrado o no está activo');
}
const primaryRow = primary.rows[0];
const mergedRow = merged.rows[0];
// Transfer devices from merged to primary
await client.query(
`UPDATE club_devices SET member_id = $1 WHERE member_id = $2`,
[primaryMemberId, mergedMemberId],
);
// Mark merged member as merged and zero out balance
await client.query(
`UPDATE club_members
SET status = 'merged', user_id = $1, current_balance_cents = 0, updated_at = now()
WHERE id = $2`,
[primaryMemberId, mergedMemberId],
);
// Credit merged balance to primary
const newBalance = Number(primaryRow.current_balance_cents) + Number(mergedRow.current_balance_cents);
const updatedPrimary = await client.query<MemberRow>(
`UPDATE club_members SET current_balance_cents = $2, updated_at = now()
WHERE id = $1 RETURNING *`,
[primaryMemberId, newBalance],
);
await client.query('COMMIT');
if (!updatedPrimary.rows[0]) throw new Error('Club merge: primary member not found after update');
return {
primary: toMember(updatedPrimary.rows[0]),
merged: toMember({
...mergedRow,
status: 'merged' as const,
current_balance_cents: 0,
}),
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ── Recovery codes ──────────────────────────────────────────────────────────
async generateRecoveryCodes(
memberId: string,
count: number,
expiresInDays: number,
): Promise<{ code: string; fingerprint: string; expiresAt: Date }[]> {
const results: { code: string; fingerprint: string; expiresAt: Date }[] = [];
const expiresAt = new Date(Date.now() + expiresInDays * 86_400_000);
const safeCount = Math.min(Math.max(count, 1), 10);
for (let i = 0; i < safeCount; i++) {
const code = generateRecoveryCode();
const codeHash = hashDeviceToken(code); // same hash fn as device tokens
const fingerprint = createHash('sha256').update(code.toUpperCase()).digest('hex').slice(0, 16);
await this.pool.query(
`INSERT INTO club_recovery_codes (member_id, code_hash, code_fingerprint, expires_at)
VALUES ($1, $2, $3, $4)`,
[memberId, codeHash, fingerprint, expiresAt],
);
results.push({ code, fingerprint, expiresAt });
}
return results;
}
async listActiveRecoveryCodes(memberId: string): Promise<ClubRecoveryCode[]> {
const result = await this.pool.query<{
id: string;
member_id: string;
code_fingerprint: string;
used_at: Date | null;
expires_at: Date;
created_at: Date;
}>(
`SELECT * FROM club_recovery_codes
WHERE member_id = $1 AND used_at IS NULL AND expires_at > now()
ORDER BY created_at DESC`,
[memberId],
);
return result.rows.map((row) => ({
id: row.id,
memberId: row.member_id,
codeFingerprint: row.code_fingerprint,
usedAt: row.used_at,
expiresAt: row.expires_at,
createdAt: row.created_at,
}));
}
async consumeRecoveryCode(
fingerprint: string,
newDeviceTokenHash: string,
): Promise<{ memberId: string; fingerprint: string }> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const codeRow = await client.query<{ id: string; member_id: string }>(
`SELECT id, member_id FROM club_recovery_codes
WHERE code_fingerprint = $1 AND used_at IS NULL AND expires_at > now()
FOR UPDATE`,
[fingerprint],
);
const row = codeRow.rows[0];
if (!row) {
await client.query('ROLLBACK');
throw new Error('Código de recuperación no encontrado o ya usado');
}
await client.query(
`UPDATE club_recovery_codes SET used_at = now() WHERE id = $1`,
[row.id],
);
await client.query(
`INSERT INTO club_devices (member_id, device_token_hash) VALUES ($1, $2)`,
[row.member_id, newDeviceTokenHash],
);
await client.query('COMMIT');
return { memberId: row.member_id, fingerprint };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ── Existing recordTransaction ─────────────────────────────────────────────
async recordTransaction(
input: RecordClubTransactionCommand,
): Promise<RecordClubTransactionResult> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
if (input.idempotencyKey) {
const existing = await client.query<TransactionRow>(
`SELECT * FROM club_transactions WHERE idempotency_key = $1`,
[input.idempotencyKey],
);
const existingRow = existing.rows[0];
if (existingRow) {
const member = await client.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1`,
[existingRow.member_id],
);
const memberRow = member.rows[0];
if (!memberRow) {
throw new Error(`club_members row not found for transaction ${existingRow.id}`);
}
await client.query('COMMIT');
return {
member: toMember(memberRow),
transaction: toTransaction(existingRow),
created: false,
};
}
}
const memberResult = await client.query<MemberRow>(
`SELECT * FROM club_members WHERE id = $1 FOR UPDATE`,
[input.memberId],
);
const member = memberResult.rows[0];
if (!member) {
throw new Error(`club_members row not found: ${input.memberId}`);
}
const nextBalance = Number(member.current_balance_cents) + input.balanceDeltaCents;
if (nextBalance < 0) {
throw new ClubInsufficientBalanceError();
}
const transactionResult = await client.query<TransactionRow>(
`INSERT INTO club_transactions (
member_id, sale_id, store_id, type, amount_cents,
balance_delta_cents, idempotency_key, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
RETURNING *`,
[
input.memberId,
input.saleId ?? null,
input.storeId ?? null,
input.type,
input.amountCents,
input.balanceDeltaCents,
input.idempotencyKey ?? null,
JSON.stringify(input.metadata ?? {}),
],
);
const updatedMember = await client.query<MemberRow>(
`UPDATE club_members
SET current_balance_cents = $2,
updated_at = now()
WHERE id = $1
RETURNING *`,
[input.memberId, nextBalance],
);
const updatedMemberRow = updatedMember.rows[0];
const transactionRow = transactionResult.rows[0];
if (!updatedMemberRow || !transactionRow) {
throw new Error('Club transaction persistence returned no row');
}
await client.query('COMMIT');
return {
member: toMember(updatedMemberRow),
transaction: toTransaction(transactionRow),
created: true,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
function parseSettings(rows: SettingRow[]): ClubSettings {
const map = new Map(rows.map((row) => [row.key, row.value]));
return {
enabled: parseBoolean(map.get(CLUB_SETTING_KEYS.enabled), DEFAULT_CLUB_SETTINGS.enabled),
cashbackBps: parseInteger(map.get(CLUB_SETTING_KEYS.cashbackBps), DEFAULT_CLUB_SETTINGS.cashbackBps),
allowAnonymousMembers: parseBoolean(
map.get(CLUB_SETTING_KEYS.allowAnonymousMembers),
DEFAULT_CLUB_SETTINGS.allowAnonymousMembers,
),
allowRecoveryCodes: parseBoolean(
map.get(CLUB_SETTING_KEYS.allowRecoveryCodes),
DEFAULT_CLUB_SETTINGS.allowRecoveryCodes,
),
minimumRedeemAmountCents: parseInteger(
map.get(CLUB_SETTING_KEYS.minimumRedeemAmountCents),
DEFAULT_CLUB_SETTINGS.minimumRedeemAmountCents,
),
};
}
function parseBoolean(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
return raw === 'true';
}
function parseInteger(raw: string | undefined, fallback: number): number {
if (raw === undefined) return fallback;
const parsed = Number(raw);
return Number.isInteger(parsed) ? parsed : fallback;
}
function toMember(row: MemberRow): ClubMember {
return {
id: row.id,
userId: row.user_id,
memberCode: row.member_code,
status: row.status,
tierCode: row.tier_code,
currentBalanceCents: Number(row.current_balance_cents),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function toTransaction(row: TransactionRow): ClubTransaction {
return {
id: row.id,
memberId: row.member_id,
saleId: row.sale_id,
storeId: row.store_id,
type: row.type,
amountCents: Number(row.amount_cents),
balanceDeltaCents: Number(row.balance_delta_cents),
idempotencyKey: row.idempotency_key,
metadata: row.metadata ?? {},
createdAt: row.created_at,
};
}
/**
* Generate a human-readable recovery code in the format:
* XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (24 alphanumeric chars, no confusing chars)
*/
const RECOVERY_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function generateRecoveryCode(): string {
const groups: string[] = [];
for (let g = 0; g < 6; g++) {
let group = '';
const bytes = randomBytes(4);
for (let b = 0; b < 4; b++) {
group += RECOVERY_CHARS[bytes[b]! % RECOVERY_CHARS.length];
}
groups.push(group);
}
return groups.join('-');
}

View File

@@ -0,0 +1,22 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
describe('club module boundary', () => {
it('does not import other module internals directly', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(identity|users|catalog|inventory|pricing|promotions|cart|shipping|orders|payments|notifications|reviews|cms|security|pos|reporting|store-settings)\/(api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { generateDeviceToken, hashDeviceToken } from '../infrastructure/device-token.js';
import { generateMemberCode } from '../infrastructure/member-code.js';
describe('club opaque tokens and member codes', () => {
it('hashDeviceToken is deterministic sha256 hex', () => {
const token = 'club-token-example';
expect(hashDeviceToken(token)).toMatch(/^[a-f0-9]{64}$/);
expect(hashDeviceToken(token)).toBe(hashDeviceToken(token));
expect(hashDeviceToken(token)).not.toBe(token);
});
it('generateDeviceToken returns distinct opaque base64url tokens', () => {
const first = generateDeviceToken();
const second = generateDeviceToken();
expect(first).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(second).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(first).not.toBe(second);
});
it('generateMemberCode uses MDV short-code format', () => {
expect(generateMemberCode()).toMatch(/^MDV-[A-Z0-9]{8}$/);
});
});

View File

@@ -21,6 +21,32 @@ export interface InventoryRoutesDeps {
authenticate: Authenticate; authenticate: Authenticate;
} }
interface InventoryOverviewRow {
product_id: string;
product_name: string;
product_slug: string;
product_active: boolean;
product_created_at: Date;
expiration_date: string | null;
variant_id: string;
sku: string;
ean: string | null;
available_quantity: number;
net_unit_amount_cents: number | null;
offer_cents: number | null;
cost_cents: number | null;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
gross_unit_amount_cents: number | null;
margin_percent: number | null;
}
interface InventoryOverviewStatsRow {
total: number;
in_stock: number;
low_stock: number;
out_of_stock: number;
}
const variantParamSchema = z.object({ variantId: z.uuid() }); const variantParamSchema = z.object({ variantId: z.uuid() });
const productParamSchema = z.object({ productId: z.uuid() }); const productParamSchema = z.object({ productId: z.uuid() });
const availabilityQuerySchema = z.object({ const availabilityQuerySchema = z.object({
@@ -44,12 +70,165 @@ const bulkAdjustBodySchema = z.object({
items: z.array(bulkAdjustItemSchema).min(1).max(100), items: z.array(bulkAdjustItemSchema).min(1).max(100),
}); });
const inventoryOverviewFilterSchema = z.enum([
'all',
'in_stock',
'low_stock',
'out_of_stock',
'expired',
'expiring',
'low_margin',
]);
const inventoryOverviewQuerySchema = z.object({
q: z.string().trim().max(120).optional(),
filter: inventoryOverviewFilterSchema.default('all'),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).max(100_000).default(0),
});
export async function registerInventoryRoutes( export async function registerInventoryRoutes(
app: FastifyInstance, app: FastifyInstance,
deps: InventoryRoutesDeps, deps: InventoryRoutesDeps,
): Promise<void> { ): Promise<void> {
const inventory = new InventoryService(new PgInventoryRepository(deps.pool)); const inventory = new InventoryService(new PgInventoryRepository(deps.pool));
const inventoryOverviewSchema: FastifySchema = {
tags: ['Inventory'],
summary: 'Inventory overview (admin)',
querystring: {
type: 'object',
properties: {
q: { type: 'string', maxLength: 120 },
filter: {
type: 'string',
enum: ['all', 'in_stock', 'low_stock', 'out_of_stock', 'expiring', 'low_margin'],
default: 'all',
},
limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 },
offset: { type: 'integer', minimum: 0, default: 0 },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/inventory/admin/overview', { schema: inventoryOverviewSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { q, filter, limit, offset } = parseJson(inventoryOverviewQuerySchema, request.query ?? {});
const params: unknown[] = [DEFAULT_STORE_ID];
let nextParam = 2;
const searchConditions: string[] = [];
if (q) {
const placeholder = `$${nextParam++}`;
params.push(`%${q}%`);
searchConditions.push(
`(product_name ILIKE ${placeholder} OR sku ILIKE ${placeholder} OR COALESCE(ean, '') ILIKE ${placeholder})`,
);
}
const filterCondition = inventoryOverviewFilterClause(filter);
const whereSearch = searchConditions.length > 0 ? `WHERE ${searchConditions.join(' AND ')}` : '';
const whereItems = [
...searchConditions,
...(filterCondition ? [filterCondition] : []),
];
const whereItemsSql = whereItems.length > 0 ? `WHERE ${whereItems.join(' AND ')}` : '';
const orderBy = inventoryOverviewOrderBy(filter);
const baseSql = `
WITH raw AS (
SELECT
p.id AS product_id,
p.name AS product_name,
p.slug AS product_slug,
p.state = 'active' AS product_active,
p.created_at AS product_created_at,
p.expiration_date,
v.id AS variant_id,
v.sku AS variant_sku,
v.ean,
COALESCE(stock.available, 0) AS available_quantity,
price.net_unit_amount_cents,
price.offer_cents,
price.cost_cents,
price.vat_rate,
COALESCE(
price.offer_cents,
ROUND(
price.net_unit_amount_cents * CASE price.vat_rate
WHEN 'general' THEN 1.21
WHEN 'reduced' THEN 1.10
WHEN 'super-reduced' THEN 1.04
ELSE 1.21
END
)::int
) AS gross_unit_amount_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN inventory_stock stock ON stock.variant_id = v.id AND stock.store_id = $1
LEFT JOIN pricing_variant_prices price ON price.variant_id = v.id AND price.currency = 'EUR'
),
base AS (
SELECT
product_id,
product_name,
product_slug,
product_active,
product_created_at,
expiration_date,
variant_id,
variant_sku AS sku,
ean,
available_quantity,
net_unit_amount_cents,
offer_cents,
cost_cents,
vat_rate,
gross_unit_amount_cents,
CASE
WHEN cost_cents IS NULL OR gross_unit_amount_cents IS NULL OR gross_unit_amount_cents <= 0 THEN NULL
ELSE ROUND((((gross_unit_amount_cents - cost_cents)::numeric / gross_unit_amount_cents::numeric) * 100))::int
END AS margin_percent
FROM raw
)
`;
const statsResult = await deps.pool.query<InventoryOverviewStatsRow>(
`${baseSql}
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE available_quantity >= 5)::int AS in_stock,
COUNT(*) FILTER (WHERE available_quantity > 0 AND available_quantity < 5)::int AS low_stock,
COUNT(*) FILTER (WHERE available_quantity = 0)::int AS out_of_stock
FROM base
${whereSearch}`,
params,
);
const countParams = [...params, limit, offset];
const countResult = await deps.pool.query<{ total: string }>(
`${baseSql}
SELECT COUNT(*)::int AS total
FROM base
${whereItemsSql}`,
params,
);
const rows = await deps.pool.query<InventoryOverviewRow>(
`${baseSql}
SELECT *
FROM base
${whereItemsSql}
ORDER BY ${orderBy}
LIMIT $${nextParam++} OFFSET $${nextParam++}`,
countParams,
);
return reply.send({
items: rows.rows.map(serializeInventoryOverviewRow),
total: Number(countResult.rows[0]?.total ?? 0),
stats: serializeInventoryOverviewStats(statsResult.rows[0]),
});
});
const availabilitySchema: FastifySchema = { const availabilitySchema: FastifySchema = {
tags: ['Inventory'], tags: ['Inventory'],
summary: 'Check availability (público)', summary: 'Check availability (público)',
@@ -358,6 +537,92 @@ export async function registerInventoryRoutes(
client.release(); client.release();
} }
}); });
// GET /inventory/admin/notifications
// Counts of low_stock, out_of_stock, expired, expiring (≤30d), low_margin
// for the bell notification badge in admin header. Uses the same CTE as
// /inventory/admin/overview to guarantee consistency between bell counts
// and the filtered inventory page.
const inventoryNotificationsSchema: FastifySchema = {
tags: ['Inventory'],
summary: 'Inventory notification counts for admin bell',
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/inventory/admin/notifications', { schema: inventoryNotificationsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const countsSql = `
WITH raw AS (
SELECT
p.id AS product_id,
p.expiration_date,
COALESCE(stock.available, 0) AS available_quantity,
price.net_unit_amount_cents,
price.offer_cents,
price.cost_cents,
price.vat_rate,
COALESCE(
price.offer_cents,
ROUND(
price.net_unit_amount_cents * CASE price.vat_rate
WHEN 'general' THEN 1.21
WHEN 'reduced' THEN 1.10
WHEN 'super-reduced' THEN 1.04
ELSE 1.21
END
)::int
) AS gross_unit_amount_cents
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN inventory_stock stock ON stock.variant_id = v.id AND stock.store_id = $1
LEFT JOIN pricing_variant_prices price ON price.variant_id = v.id AND price.currency = 'EUR'
WHERE p.state = 'active'
),
base AS (
SELECT
expiration_date,
available_quantity,
CASE
WHEN cost_cents IS NULL OR gross_unit_amount_cents IS NULL OR gross_unit_amount_cents <= 0 THEN NULL
ELSE ROUND((((gross_unit_amount_cents - cost_cents)::numeric / gross_unit_amount_cents::numeric) * 100))::int
END AS margin_percent
FROM raw
)
SELECT
COUNT(*) FILTER (WHERE available_quantity > 0 AND available_quantity < 5)::int AS low_stock,
COUNT(*) FILTER (WHERE available_quantity = 0)::int AS out_of_stock,
COUNT(*) FILTER (WHERE expiration_date IS NOT NULL AND expiration_date < current_date)::int AS expired,
COUNT(*) FILTER (WHERE expiration_date IS NOT NULL AND expiration_date >= current_date AND expiration_date <= current_date + 30)::int AS expiring_soon,
COUNT(*) FILTER (WHERE margin_percent IS NOT NULL AND margin_percent < 30)::int AS low_margin
FROM base
`;
const result = await deps.pool.query<{
low_stock: number;
out_of_stock: number;
expired: number;
expiring_soon: number;
low_margin: number;
}>(countsSql, [DEFAULT_STORE_ID]);
const r = result.rows[0] ?? {
low_stock: 0, out_of_stock: 0, expired: 0, expiring_soon: 0, low_margin: 0,
};
const candidates: Array<{ id: string; count: number; href: string; label: string; tone: 'amber' | 'red' }> = [
{ id: 'low_stock', count: Number(r.low_stock ?? 0), href: '/inventory?filter=low_stock', label: 'Stock bajo', tone: 'amber' },
{ id: 'out_of_stock', count: Number(r.out_of_stock ?? 0), href: '/inventory?filter=out_of_stock', label: 'Sin stock', tone: 'red' },
{ id: 'expired', count: Number(r.expired ?? 0), href: '/inventory?filter=expired', label: 'Caducados', tone: 'red' },
{ id: 'expiring_soon', count: Number(r.expiring_soon ?? 0), href: '/inventory?filter=expiring', label: 'Por caducar (≤30d)', tone: 'amber' },
{ id: 'low_margin', count: Number(r.low_margin ?? 0), href: '/inventory?filter=low_margin', label: 'Margen <30%', tone: 'red' },
];
const items = candidates.filter((c) => c.count > 0);
const total = items.reduce((sum, c) => sum + c.count, 0);
return reply.send({ items, total });
});
} }
function mapInventoryError(error: unknown): Error { function mapInventoryError(error: unknown): Error {
@@ -385,3 +650,73 @@ function serializeStockItem(item: StockItem) {
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
}; };
} }
function inventoryOverviewFilterClause(
filter: z.infer<typeof inventoryOverviewFilterSchema>,
): string {
if (filter === 'in_stock') return 'available_quantity >= 5';
if (filter === 'low_stock') return 'available_quantity > 0 AND available_quantity < 5';
if (filter === 'out_of_stock') return 'available_quantity = 0';
if (filter === 'expired') {
return 'expiration_date IS NOT NULL AND expiration_date < current_date';
}
if (filter === 'expiring') {
return "expiration_date IS NOT NULL AND expiration_date <= current_date + 30";
}
if (filter === 'low_margin') return 'margin_percent IS NOT NULL AND margin_percent < 30';
return '';
}
function inventoryOverviewOrderBy(filter: z.infer<typeof inventoryOverviewFilterSchema>): string {
if (filter === 'expired') {
return 'expiration_date DESC NULLS LAST, product_name ASC, sku ASC';
}
if (filter === 'expiring') {
return 'expiration_date ASC NULLS LAST, product_name ASC, sku ASC';
}
if (filter === 'low_margin') {
return 'margin_percent ASC NULLS LAST, product_name ASC, sku ASC';
}
return 'product_created_at DESC, product_name ASC, sku ASC';
}
function serializeInventoryOverviewRow(row: InventoryOverviewRow) {
return {
productId: row.product_id,
productName: row.product_name,
productSlug: row.product_slug,
productActive: row.product_active,
expirationDate: row.expiration_date,
variant: {
id: row.variant_id,
productId: row.product_id,
sku: row.sku,
ean: row.ean,
attributes: {},
},
stock: {
available: row.available_quantity > 0,
availableQuantity: Number(row.available_quantity),
},
price: row.net_unit_amount_cents === null || row.vat_rate === null
? null
: {
variantId: row.variant_id,
netUnitAmountCents: Number(row.net_unit_amount_cents),
offerCents: row.offer_cents === null ? null : Number(row.offer_cents),
costCents: row.cost_cents === null ? null : Number(row.cost_cents),
vatRate: row.vat_rate,
currency: 'EUR',
},
marginPercent: row.margin_percent === null ? null : Number(row.margin_percent),
};
}
function serializeInventoryOverviewStats(row: InventoryOverviewStatsRow | undefined) {
return {
total: Number(row?.total ?? 0),
inStock: Number(row?.in_stock ?? 0),
lowStock: Number(row?.low_stock ?? 0),
outOfStock: Number(row?.out_of_stock ?? 0),
};
}

View File

@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type pg from 'pg';
import Fastify, { type FastifyInstance } from 'fastify';
import { AppError, errorEnvelope } from '../../../shared/errors.js';
import { registerInventoryRoutes } from '../api/inventory.routes.js';
import type { InventoryRoutesDeps } from '../api/inventory.routes.js';
const created: FastifyInstance[] = [];
const ADMIN = { id: 'a1', email: 'ana@example.com', role: 'admin' };
const CUSTOMER = { id: 'c1', email: 'c1@example.com', role: 'customer' };
/**
* Minimal app: inventory routes + mocked authenticator (no DB).
* Mirrors build-app.ts error mapping so AppError(403) surfaces with real
* status code.
*/
async function buildApp(authenticatedUser: unknown, mockRows: Record<string, number> = {}) {
const app = Fastify();
created.push(app);
app.setSerializerCompiler(() => (payload: unknown) => JSON.stringify(payload));
app.setErrorHandler(async (err, _request, reply) => {
if (err instanceof AppError) {
return reply
.code(err.statusCode)
.send(errorEnvelope(err.statusCode, err.code, err.message, 'test-request-id', err.details));
}
return reply
.code(500)
.send(errorEnvelope(500, 'INTERNAL_ERROR', 'Internal Server Error', 'test-request-id'));
});
const mockPool = {
query: vi.fn().mockResolvedValue({
rows: [
{
low_stock: mockRows.low_stock ?? 0,
out_of_stock: mockRows.out_of_stock ?? 0,
expired: mockRows.expired ?? 0,
expiring_soon: mockRows.expiring_soon ?? 0,
low_margin: mockRows.low_margin ?? 0,
},
],
}),
} as unknown as pg.Pool;
const deps = {
authenticate: vi.fn().mockResolvedValue(authenticatedUser),
pool: mockPool,
} as unknown as InventoryRoutesDeps;
await registerInventoryRoutes(app, deps);
await app.ready();
return { app };
}
afterEach(async () => {
for (const app of created) {
try {
await app.close();
} catch {
/* ignore */
}
}
created.length = 0;
});
describe('GET /inventory/admin/notifications', () => {
it('admin receives items with count > 0 and correct total (AC1, AC2)', async () => {
const { app } = await buildApp(ADMIN, {
low_stock: 3,
out_of_stock: 2,
expired: 1,
expiring_soon: 5,
low_margin: 4,
});
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(200);
const body = res.json() as {
items: Array<{ id: string; count: number; href: string; label: string; tone: 'amber' | 'red' }>;
total: number;
};
expect(body.total).toBe(15);
expect(body.items.map((i) => i.id)).toEqual([
'low_stock',
'out_of_stock',
'expired',
'expiring_soon',
'low_margin',
]);
expect(body.items[0]).toMatchObject({
id: 'low_stock',
count: 3,
href: '/inventory?filter=low_stock',
label: 'Stock bajo',
tone: 'amber',
});
expect(body.items[2]).toMatchObject({
id: 'expired',
count: 1,
href: '/inventory?filter=expired',
label: 'Caducados',
tone: 'red',
});
expect(body.items[4]).toMatchObject({
id: 'low_margin',
count: 4,
href: '/inventory?filter=low_margin',
label: 'Margen <30%',
tone: 'red',
});
});
it('omits items with count = 0', async () => {
const { app } = await buildApp(ADMIN, { low_stock: 0, expired: 2 });
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(200);
const body = res.json() as { items: Array<{ id: string; count: number }>; total: number };
expect(body.items.map((i) => i.id)).toEqual(['expired']);
expect(body.total).toBe(2);
});
it('returns empty items and total 0 when no alerts', async () => {
const { app } = await buildApp(ADMIN);
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(200);
const body = res.json() as { items: unknown[]; total: number };
expect(body.items).toEqual([]);
expect(body.total).toBe(0);
});
it('rejects non-admin with HTTP 403', async () => {
const { app } = await buildApp(CUSTOMER);
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(403);
});
it('expiring_soon uses /inventory?filter=expiring (not a separate filter)', async () => {
const { app } = await buildApp(ADMIN, { expiring_soon: 7 });
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
const body = res.json() as { items: Array<{ id: string; href: string }> };
const expiring = body.items.find((i) => i.id === 'expiring_soon');
expect(expiring?.href).toBe('/inventory?filter=expiring');
});
});

View File

@@ -546,9 +546,7 @@ export async function registerOrdersRoutes(
COUNT(*) OVER()::int AS total_count COUNT(*) OVER()::int AS total_count
FROM orders_orders o FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id LEFT JOIN identity_users u ON u.id = o.user_id
WHERE o.payment_status = 'AWAITING' WHERE o.state = 'AWAITING_PAYMENT'
AND o.state = 'PENDING'
AND o.deleted_at IS NULL
ORDER BY o.created_at ASC ORDER BY o.created_at ASC
LIMIT 20`, LIMIT 20`,
); );

View File

@@ -57,6 +57,53 @@ 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;
}
const POS_GROSS_PRICE_SQL = `COALESCE(
pp.offer_cents,
ROUND(
pp.net_unit_amount_cents * CASE pp.vat_rate
WHEN 'general' THEN 1.21
WHEN 'reduced' THEN 1.10
WHEN 'super-reduced' THEN 1.04
ELSE 1.21
END
)::int,
0
)`;
function grossFromNet(
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced' | null,
): number {
if (!vatRate) return netUnitAmountCents;
const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400;
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) { export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps; const { pool, authenticate } = deps;
@@ -136,12 +183,13 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
contactPhone: z.string().optional(), contactPhone: z.string().optional(),
receiptHeader: z.string().optional(), receiptHeader: z.string().optional(),
receiptFooter: z.string().optional(), receiptFooter: z.string().optional(),
logoUrl: z.string().url().max(500).optional(),
}), }),
request.body ?? {}, request.body ?? {},
); );
const result = await pool.query<{ id: string; name: string; slug: string; active: boolean }>( const result = await pool.query<{ id: string; name: string; slug: string; active: boolean }>(
`INSERT INTO pos_stores (name, slug, address, tax_id, contact_email, contact_phone, receipt_header, receipt_footer) `INSERT INTO pos_stores (name, slug, address, tax_id, contact_email, contact_phone, receipt_header, receipt_footer, logo_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, name, slug, active`, RETURNING id, name, slug, active`,
[ [
body.name, body.name,
@@ -152,6 +200,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
body.contactPhone, body.contactPhone,
body.receiptHeader, body.receiptHeader,
body.receiptFooter, body.receiptFooter,
body.logoUrl ?? null,
], ],
); );
return reply.code(201).send(result.rows[0]); return reply.code(201).send(result.rows[0]);
@@ -234,21 +283,59 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
{ {
schema: { schema: {
tags: ['POS Admin'], tags: ['POS Admin'],
summary: 'List products available for TPV touch configuration', summary: 'Search products available for TPV touch configuration',
querystring: {
type: 'object',
properties: {
q: { type: 'string', minLength: 1, maxLength: 120 },
limit: { type: 'integer', minimum: 1, maximum: 40, default: 12 },
variantIds: { type: 'string' },
},
},
response: { 401: errorSchema, 403: errorSchema }, response: { 401: errorSchema, 403: errorSchema },
} as FastifySchema, } as FastifySchema,
}, },
async (request, reply) => { async (request, reply) => {
const user = await authenticate(request); const user = await authenticate(request);
requireRole(user, 'admin'); requireRole(user, 'admin');
const result = await pool.query( const query = parseJson(
z.object({
q: z.string().trim().min(1).max(120).optional(),
limit: z.coerce.number().int().min(1).max(40).default(12),
variantIds: z.string().optional(),
}),
request.query ?? {},
);
const variantIds = (query.variantIds ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (variantIds.some((value) => !storeIdSchema.safeParse(value).success)) {
throw new AppError(400, 'INVALID_VARIANT_ID', 'Algún producto rápido no es válido');
}
const result = variantIds.length > 0
? await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean, `SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents" ${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE p.state = 'active' AND v.id = ANY($1::uuid[])
ORDER BY p.name, v.sku`,
[variantIds],
)
: await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE p.state = 'active' WHERE p.state = 'active'
ORDER BY p.name, v.sku`, AND ($1::text IS NULL OR p.name ILIKE $1 OR v.sku ILIKE $1 OR COALESCE(v.ean, '') ILIKE $1)
ORDER BY p.name, v.sku
LIMIT $2`,
[query.q ? `%${query.q}%` : null, query.limit],
); );
return reply.send({ items: result.rows }); return reply.send({ items: result.rows });
}, },
@@ -696,15 +783,13 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
[id], [id],
), ),
pool.query<{ method_code: string; method_name: string; total: string; count: string }>( pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
`SELECT pm.code AS method_code, pm.name AS method_name, `SELECT pm.code AS method_code, pm.label AS method_name,
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total, COALESCE(SUM(rpl.amount_cents), 0)::bigint AS total,
COUNT(*)::int AS count COUNT(*)::int AS count
FROM payments_transactions pt FROM reporting_payment_lines rpl
JOIN orders_orders o ON o.id = pt.order_id LEFT JOIN pos_payment_methods pm ON pm.id = rpl.payment_method_id
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id WHERE rpl.cash_session_id = $1 AND rpl.status = 'payment'
AND pm.store_id = o.store_id GROUP BY pm.code, pm.label`,
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
GROUP BY pm.code, pm.name`,
[id], [id],
), ),
pool.query<{ items_count: string; unique_products: string }>( pool.query<{ items_count: string; unique_products: string }>(
@@ -768,15 +853,13 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
[id], [id],
), ),
pool.query<{ method_code: string; method_name: string; total: string; count: string }>( pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
`SELECT pm.code AS method_code, pm.name AS method_name, `SELECT pm.code AS method_code, pm.label AS method_name,
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total, COALESCE(SUM(rpl.amount_cents), 0)::bigint AS total,
COUNT(*)::int AS count COUNT(*)::int AS count
FROM payments_transactions pt FROM reporting_payment_lines rpl
JOIN orders_orders o ON o.id = pt.order_id LEFT JOIN pos_payment_methods pm ON pm.id = rpl.payment_method_id
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id WHERE rpl.cash_session_id = $1 AND rpl.status = 'payment'
AND pm.store_id = o.store_id GROUP BY pm.code, pm.label`,
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
GROUP BY pm.code, pm.name`,
[id], [id],
), ),
pool.query<{ items_count: string; unique_products: string }>( pool.query<{ items_count: string; unique_products: string }>(
@@ -881,7 +964,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
pool.query( pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean, `SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock, pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents" ${POS_GROSS_PRICE_SQL} AS "priceCents"
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
@@ -947,7 +1030,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query( const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean, `SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock, COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents, ${POS_GROSS_PRICE_SQL} AS price_cents,
c.name AS category, b.name AS brand c.name AS category, b.name AS brand
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
@@ -1003,7 +1086,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query( const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean, `SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock, COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents ${POS_GROSS_PRICE_SQL} AS price_cents
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -1045,7 +1128,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const result = await pool.query( const result = await pool.query(
`SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean, `SELECT v.id AS variant_id, v.product_id, p.name, v.sku, v.ean,
COALESCE(s.quantity, 0) AS stock, COALESCE(s.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS price_cents ${POS_GROSS_PRICE_SQL} AS price_cents
FROM catalog_product_variants v FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -1210,8 +1293,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
summary: 'Get company and receipt numbering settings', summary: 'Get company and receipt numbering settings',
querystring: { querystring: {
type: 'object', type: 'object',
required: ['storeId'], properties: { storeId: { type: 'string' } },
properties: { storeId: { type: 'string', format: 'uuid' } },
}, },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema, } as FastifySchema,
@@ -1219,7 +1301,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => { async (request, reply) => {
const user = await authenticate(request); const user = await authenticate(request);
requireRole(user, 'admin'); 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( await pool.query(
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1) `INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
ON CONFLICT (store_id) DO NOTHING`, ON CONFLICT (store_id) DO NOTHING`,
@@ -1268,6 +1354,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
contactPhone: z.string().trim().max(64).optional(), contactPhone: z.string().trim().max(64).optional(),
receiptHeader: z.string().trim().max(500).optional(), receiptHeader: z.string().trim().max(500).optional(),
receiptFooter: z.string().trim().max(1000).optional(), receiptFooter: z.string().trim().max(1000).optional(),
logoUrl: z.string().url().max(500).optional(),
prefix: z prefix: z
.string() .string()
.trim() .trim()
@@ -1288,7 +1375,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
tax_id = COALESCE($4, tax_id), contact_email = COALESCE($5, contact_email), tax_id = COALESCE($4, tax_id), contact_email = COALESCE($5, contact_email),
contact_phone = COALESCE($6, contact_phone), contact_phone = COALESCE($6, contact_phone),
receipt_header = COALESCE($7, receipt_header), receipt_header = COALESCE($7, receipt_header),
receipt_footer = COALESCE($8, receipt_footer), updated_at = now() receipt_footer = COALESCE($8, receipt_footer),
logo_url = $9, updated_at = now()
WHERE id = $1 RETURNING id`, WHERE id = $1 RETURNING id`,
[ [
body.storeId, body.storeId,
@@ -1299,6 +1387,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
body.contactPhone, body.contactPhone,
body.receiptHeader, body.receiptHeader,
body.receiptFooter, body.receiptFooter,
body.logoUrl ?? null,
], ],
); );
if (!store.rows[0]) throw new AppError(404, 'STORE_NOT_FOUND', 'No se encontró la tienda'); if (!store.rows[0]) throw new AppError(404, 'STORE_NOT_FOUND', 'No se encontró la tienda');
@@ -1344,6 +1433,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
payments: { type: 'array', minItems: 0, items: { type: 'object' } }, payments: { type: 'array', minItems: 0, items: { type: 'object' } },
customerId: { type: 'string', format: 'uuid' }, customerId: { type: 'string', format: 'uuid' },
posLabel: { type: 'string', maxLength: 100 }, posLabel: { type: 'string', maxLength: 100 },
/** Club member UUID — if provided and sale completes, cashback is accumulated. */
clubMemberId: { type: 'string', format: 'uuid' },
}, },
}, },
response: { response: {
@@ -1396,6 +1487,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
payments: z.array(payment).min(0), payments: z.array(payment).min(0),
customerId: z.string().uuid().optional(), customerId: z.string().uuid().optional(),
posLabel: z.string().max(100).optional(), posLabel: z.string().max(100).optional(),
clubMemberId: z.string().uuid().optional(),
}), }),
request.body ?? {}, request.body ?? {},
); );
@@ -1612,10 +1704,10 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
querystring: { querystring: {
type: 'object', type: 'object',
properties: { properties: {
sessionId: { type: 'string', format: 'uuid' }, sessionId: { type: 'string' },
terminalId: { type: 'string', format: 'uuid' }, terminalId: { type: 'string' },
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] }, state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
storeId: { type: 'string', format: 'uuid' }, storeId: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
}, },
}, },
@@ -2078,23 +2170,36 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
is_free_item: boolean; is_free_item: boolean;
unit_price_cents: number; unit_price_cents: number;
discount_cents: number; discount_cents: number;
tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
}>( }>(
`SELECT id, name, sku, quantity, returned_quantity, is_free_item, `SELECT id, name, sku, quantity, returned_quantity, is_free_item,
unit_price_cents, discount_cents unit_price_cents, discount_cents, tax_cents, vat_rate
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`, FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[id], [id],
); );
return reply.send({ return reply.send({
items: itemRows.rows.map((row) => ({ items: itemRows.rows.map((row) => {
const unitNetCents = Number(row.unit_price_cents);
const unitTaxCents = Number(row.tax_cents);
const unitGrossCents = row.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, row.vat_rate);
const discountGrossCents = Math.max(
0,
unitGrossCents - (unitNetCents - Number(row.discount_cents) + unitTaxCents),
);
return {
id: row.id, id: row.id,
name: row.name, name: row.name,
sku: row.sku, sku: row.sku,
quantity: Number(row.quantity), quantity: Number(row.quantity),
returnedQuantity: Number(row.returned_quantity), returnedQuantity: Number(row.returned_quantity),
freeItem: row.is_free_item, freeItem: row.is_free_item,
unitPriceCents: Number(row.unit_price_cents), unitPriceCents: unitGrossCents,
discountCents: Number(row.discount_cents), discountCents: discountGrossCents,
})), };
}),
}); });
}, },
); );

View File

@@ -15,6 +15,7 @@ interface OrderRow {
interface OrderItemRow { interface OrderItemRow {
id: string; id: string;
name: string;
quantity: number; quantity: number;
returned_quantity: number; returned_quantity: number;
is_free_item: boolean; is_free_item: boolean;
@@ -131,7 +132,7 @@ export class ApplyPosReturnUseCase {
} }
const itemRows = await client.query<OrderItemRow>( 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 unit_price_cents, discount_cents, tax_cents
FROM orders_items FROM orders_items
WHERE order_id = $1 WHERE order_id = $1
@@ -174,7 +175,7 @@ export class ApplyPosReturnUseCase {
); );
} }
totalRefundCents += totalRefundCents +=
(item.unit_price_cents - item.discount_cents) * line.returnedQuantity; (item.unit_price_cents - item.discount_cents + item.tax_cents) * line.returnedQuantity;
if (!item.is_free_item && item.variant_id) { if (!item.is_free_item && item.variant_id) {
stockUpdates.push({ stockUpdates.push({
variantId: item.variant_id, variantId: item.variant_id,
@@ -315,10 +316,18 @@ export class ApplyPosReturnUseCase {
], ],
); );
await client.query( await client.query(
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email) `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))`, VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3), $4)`,
[ [
input.orderId, input.orderId,
formatReturnMessage({
refundedCents: totalRefundCents,
status: reportingStatus,
lines: input.lines,
reason: input.reason,
items: itemsById,
}),
input.userId,
JSON.stringify({ JSON.stringify({
returnId, returnId,
refundedCents: totalRefundCents, refundedCents: totalRefundCents,
@@ -326,7 +335,6 @@ export class ApplyPosReturnUseCase {
lines: input.lines, lines: input.lines,
reason: input.reason, 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,5 +1,10 @@
import type pg from 'pg'; import type pg from 'pg';
import type { PosPaymentKind, PosReceipt, PosReceiptPayment } from '../domain/pos-sale.js'; import type {
PosPaymentKind,
PosReceipt,
PosReceiptPayment,
PosReceiptTaxBreakdown,
} from '../domain/pos-sale.js';
import { AppError } from '../../../shared/errors.js'; import { AppError } from '../../../shared/errors.js';
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>; type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
@@ -11,6 +16,7 @@ interface ReturnedItemRow {
unit_price_cents: number; unit_price_cents: number;
discount_cents: number; discount_cents: number;
tax_cents: number; tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
name: string; name: string;
sku: string; sku: string;
is_free_item: boolean; is_free_item: boolean;
@@ -37,6 +43,7 @@ interface ReceiptOrderRow {
terminal_name: string; terminal_name: string;
cashier_email: string; cashier_email: string;
return_policy: string | null; return_policy: string | null;
logo_url: string | null;
} }
interface ReceiptItemRow { interface ReceiptItemRow {
@@ -46,6 +53,7 @@ interface ReceiptItemRow {
unit_price_cents: number; unit_price_cents: number;
discount_cents: number; discount_cents: number;
tax_cents: number; tax_cents: number;
vat_rate: 'general' | 'reduced' | 'super-reduced' | null;
is_free_item: boolean; is_free_item: boolean;
} }
@@ -61,7 +69,7 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
o.total_cents, o.created_at, o.cash_session_id, o.terminal_id, o.total_cents, o.created_at, o.cash_session_id, o.terminal_id,
customer.email AS customer_email, customer.email AS customer_email,
store.name AS store_name, store.address, store.tax_id, store.contact_email, store.name AS store_name, store.address, store.tax_id, store.contact_email,
store.contact_phone, store.receipt_header, store.receipt_footer, store.contact_phone, store.receipt_header, store.receipt_footer, store.logo_url,
terminal.name AS terminal_name, cashier.email AS cashier_email, terminal.name AS terminal_name, cashier.email AS cashier_email,
receipt_settings.return_policy receipt_settings.return_policy
FROM orders_orders o FROM orders_orders o
@@ -79,7 +87,7 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
const [itemResult, paymentResult] = await Promise.all([ const [itemResult, paymentResult] = await Promise.all([
queryable.query<ReceiptItemRow>( queryable.query<ReceiptItemRow>(
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, is_free_item `SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, vat_rate, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`, FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId], [orderId],
), ),
@@ -117,30 +125,40 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
email: order.contact_email, email: order.contact_email,
phone: order.contact_phone, phone: order.contact_phone,
}, },
logoUrl: order.logo_url,
terminal: { id: order.terminal_id, name: order.terminal_name }, terminal: { id: order.terminal_id, name: order.terminal_name },
cashier: order.cashier_email, cashier: order.cashier_email,
sessionId: order.cash_session_id, sessionId: order.cash_session_id,
customerEmail: order.customer_email, customerEmail: order.customer_email,
items: itemResult.rows.map((item) => { items: itemResult.rows.map((item) => {
const subtotalCents = Number(item.unit_price_cents) * Number(item.quantity); const quantity = Number(item.quantity);
const discountCents = Number(item.discount_cents) * Number(item.quantity); const unitNetCents = Number(item.unit_price_cents);
const taxCents = Number(item.tax_cents) * Number(item.quantity); const unitTaxCents = Number(item.tax_cents);
const unitGrossCents = item.is_free_item
? unitNetCents
: grossFromNet(unitNetCents, item.vat_rate);
const totalCents = (unitNetCents - Number(item.discount_cents) + unitTaxCents) * quantity;
const subtotalCents = unitGrossCents * quantity;
const discountCents = Math.max(0, subtotalCents - totalCents);
const taxCents = unitTaxCents * quantity;
return { return {
name: item.name, name: item.name,
sku: item.sku, sku: item.sku,
quantity: Number(item.quantity), quantity,
unitPriceCents: Number(item.unit_price_cents), unitPriceCents: unitGrossCents,
subtotalCents, subtotalCents,
discountCents, discountCents,
taxCents, taxCents,
totalCents: subtotalCents - discountCents + taxCents, totalCents,
freeItem: item.is_free_item, freeItem: item.is_free_item,
vatRate: item.vat_rate,
}; };
}), }),
subtotalCents: Number(order.subtotal_cents), subtotalCents: Number(order.subtotal_cents),
discountCents: Number(order.discount_cents), discountCents: Number(order.discount_cents),
taxCents: Number(order.tax_cents), taxCents: Number(order.tax_cents),
totalCents: Number(order.total_cents), totalCents: Number(order.total_cents),
taxBreakdown: computeTaxBreakdown(itemResult.rows),
payments, payments,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0), changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
header: order.receipt_header, header: order.receipt_header,
@@ -150,6 +168,51 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
}; };
} }
function grossFromNet(
netUnitAmountCents: number,
vatRate: 'general' | 'reduced' | 'super-reduced' | null,
): number {
if (!vatRate) return netUnitAmountCents;
const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400;
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
}
/**
* Compute the per-VAT-rate breakdown for a set of receipt item rows.
* Items with unknown VAT (null) or marked as free items are ignored.
*/
function computeTaxBreakdown(
rows: ReceiptItemRow[],
): PosReceiptTaxBreakdown {
const result: PosReceiptTaxBreakdown = {
generalBaseCents: 0,
generalTaxCents: 0,
reducedBaseCents: 0,
reducedTaxCents: 0,
superReducedBaseCents: 0,
superReducedTaxCents: 0,
};
for (const row of rows) {
if (row.is_free_item || !row.vat_rate) continue;
const quantity = Number(row.quantity);
const unitNet = Number(row.unit_price_cents);
const unitTax = Number(row.tax_cents);
const base = unitNet * quantity;
const tax = unitTax * quantity;
if (row.vat_rate === 'general') {
result.generalBaseCents += base;
result.generalTaxCents += tax;
} else if (row.vat_rate === 'reduced') {
result.reducedBaseCents += base;
result.reducedTaxCents += tax;
} else if (row.vat_rate === 'super-reduced') {
result.superReducedBaseCents += base;
result.superReducedTaxCents += tax;
}
}
return result;
}
function isPaymentKind(value: unknown): value is PosPaymentKind { function isPaymentKind(value: unknown): value is PosPaymentKind {
return value === 'cash' || value === 'card' || value === 'other'; return value === 'cash' || value === 'card' || value === 'other';
} }
@@ -194,7 +257,7 @@ export async function buildPosReturnReceipt(
const original = await buildPosReceipt(queryable, orderId); const original = await buildPosReceipt(queryable, orderId);
const itemResult = await queryable.query<ReturnedItemRow>( const itemResult = await queryable.query<ReturnedItemRow>(
`SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents, `SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents,
name, sku, is_free_item vat_rate, name, sku, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`, FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId], [orderId],
); );
@@ -202,23 +265,26 @@ export async function buildPosReturnReceipt(
.filter((row) => row.returned_quantity > 0) .filter((row) => row.returned_quantity > 0)
.map((row) => { .map((row) => {
const returnedQuantity = Number(row.returned_quantity); const returnedQuantity = Number(row.returned_quantity);
const unitPrice = Number(row.unit_price_cents); const unitNetCents = Number(row.unit_price_cents);
const discount = Number(row.discount_cents); const unitTaxCents = Number(row.tax_cents);
const tax = Number(row.tax_cents); const unitGrossCents = row.is_free_item
const subtotal = unitPrice * returnedQuantity; ? unitNetCents
const discountCents = discount * returnedQuantity; : grossFromNet(unitNetCents, row.vat_rate);
const taxCents = tax * returnedQuantity; const total = (unitNetCents - Number(row.discount_cents) + unitTaxCents) * returnedQuantity;
const total = subtotal - discountCents + taxCents; const subtotal = unitGrossCents * returnedQuantity;
const discountCents = Math.max(0, subtotal - total);
const taxCents = unitTaxCents * returnedQuantity;
return { return {
name: row.name, name: row.name,
sku: row.sku, sku: row.sku,
quantity: returnedQuantity, quantity: returnedQuantity,
unitPriceCents: unitPrice, unitPriceCents: unitGrossCents,
subtotalCents: subtotal, subtotalCents: subtotal,
discountCents, discountCents,
taxCents, taxCents,
totalCents: -total, totalCents: -total,
freeItem: row.is_free_item, freeItem: row.is_free_item,
vatRate: row.vat_rate,
}; };
}); });
const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0); const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0);
@@ -231,6 +297,7 @@ export async function buildPosReturnReceipt(
orderId: original.orderId, orderId: original.orderId,
issuedAt: original.issuedAt, issuedAt: original.issuedAt,
company: original.company, company: original.company,
logoUrl: original.logoUrl,
terminal: original.terminal, terminal: original.terminal,
cashier: original.cashier, cashier: original.cashier,
sessionId: original.sessionId, sessionId: original.sessionId,
@@ -240,6 +307,14 @@ export async function buildPosReturnReceipt(
discountCents: -discount, discountCents: -discount,
taxCents: -tax, taxCents: -tax,
totalCents: -refundedCents, totalCents: -refundedCents,
taxBreakdown: {
generalBaseCents: -original.taxBreakdown.generalBaseCents,
generalTaxCents: -original.taxBreakdown.generalTaxCents,
reducedBaseCents: -original.taxBreakdown.reducedBaseCents,
reducedTaxCents: -original.taxBreakdown.reducedTaxCents,
superReducedBaseCents: -original.taxBreakdown.superReducedBaseCents,
superReducedTaxCents: -original.taxBreakdown.superReducedTaxCents,
},
payments: [], payments: [],
changeCents: 0, changeCents: 0,
header: original.header, header: original.header,

View File

@@ -24,10 +24,17 @@ interface CatalogLineRow {
sku: string; sku: string;
ean: string | null; ean: string | null;
name: string; name: string;
unit_price_cents: number; net_unit_amount_cents: number;
vat_rate: string; offer_cents: number | null;
vat_rate: 'general' | 'reduced' | 'super-reduced';
} }
const POS_VAT_BASIS_POINTS: Record<CatalogLineRow['vat_rate'], number> = {
general: 2100,
reduced: 1000,
'super-reduced': 400,
};
export interface ConfiguredPaymentMethod { export interface ConfiguredPaymentMethod {
id: string; id: string;
code: string; code: string;
@@ -104,7 +111,10 @@ export function validatePaymentAllocations(
}; };
}); });
const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0); const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0);
if (allocatedCents > totalCents) { // Allow up to 1 cent tolerance for rounding differences between the front-end
// POS calculation (which uses cached prices) and the back-end canonical total
// (which re-reads the catalogue under a transaction).
if (allocatedCents - totalCents > 1) {
throw new AppError( throw new AppError(
400, 400,
'POS_PAYMENT_OVERPAYMENT', 'POS_PAYMENT_OVERPAYMENT',
@@ -114,11 +124,25 @@ export function validatePaymentAllocations(
return validated; return validated;
} }
function grossFromNet(netUnitAmountCents: number, vatRate: CatalogLineRow['vat_rate']): number {
return Math.round(netUnitAmountCents * (1 + POS_VAT_BASIS_POINTS[vatRate] / 10_000));
}
function netFromGross(grossUnitAmountCents: number, vatRate: CatalogLineRow['vat_rate']): number {
return Math.round(grossUnitAmountCents / (1 + POS_VAT_BASIS_POINTS[vatRate] / 10_000));
}
function effectiveGrossUnitPrice(catalog: CatalogLineRow): number {
return catalog.offer_cents ?? grossFromNet(Number(catalog.net_unit_amount_cents), catalog.vat_rate);
}
export class CreatePosSaleUseCase { export class CreatePosSaleUseCase {
constructor(private readonly pool: pg.Pool) {} constructor(private readonly pool: pg.Pool) {}
async execute(input: PosSaleInput): Promise<PosSaleResult> { async execute(input: PosSaleInput): Promise<PosSaleResult> {
const client = await this.pool.connect(); const client = await this.pool.connect();
let clubEarnedCents = 0;
let clubMemberCode: string | undefined;
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
@@ -153,6 +177,9 @@ export class CreatePosSaleUseCase {
throw new AppError(400, 'POS_EMPTY_CART', 'El carrito está vacío'); throw new AppError(400, 'POS_EMPTY_CART', 'El carrito está vacío');
const lineDiscountsEnabled = session.terminal_settings?.lineDiscountsEnabled !== false; const lineDiscountsEnabled = session.terminal_settings?.lineDiscountsEnabled !== false;
let subtotalCents = 0;
let discountCents = 0;
let taxCents = 0;
const items: PosSaleLineItem[] = []; const items: PosSaleLineItem[] = [];
for (const inputItem of input.items) { for (const inputItem of input.items) {
if (inputItem.kind === 'free') { if (inputItem.kind === 'free') {
@@ -168,6 +195,7 @@ export class CreatePosSaleUseCase {
'El artículo libre requiere nombre y precio positivo', 'El artículo libre requiere nombre y precio positivo',
); );
} }
subtotalCents += inputItem.unitPriceCents * inputItem.quantity;
items.push({ items.push({
kind: 'free', kind: 'free',
variantId: null, variantId: null,
@@ -187,7 +215,8 @@ export class CreatePosSaleUseCase {
const catalogResult = await client.query<CatalogLineRow>( const catalogResult = await client.query<CatalogLineRow>(
`SELECT variant.id AS variant_id, variant.product_id, variant.sku, variant.ean, `SELECT variant.id AS variant_id, variant.product_id, variant.sku, variant.ean,
product.name, product.name,
COALESCE(price.offer_cents, price.net_unit_amount_cents) AS unit_price_cents, price.net_unit_amount_cents,
price.offer_cents,
price.vat_rate price.vat_rate
FROM catalog_product_variants variant FROM catalog_product_variants variant
JOIN catalog_products product ON product.id = variant.product_id JOIN catalog_products product ON product.id = variant.product_id
@@ -198,34 +227,43 @@ export class CreatePosSaleUseCase {
const catalog = catalogResult.rows[0]; const catalog = catalogResult.rows[0];
if (!catalog) if (!catalog)
throw new AppError(404, 'POS_PRODUCT_NOT_FOUND', 'El producto ya no está disponible'); throw new AppError(404, 'POS_PRODUCT_NOT_FOUND', 'El producto ya no está disponible');
const discountCents = inputItem.discountCents ?? 0; const grossUnitPriceCents = effectiveGrossUnitPrice(catalog);
const discountGrossCents = inputItem.discountCents ?? 0;
if ( if (
!Number.isInteger(discountCents) || !Number.isInteger(discountGrossCents) ||
discountCents < 0 || discountGrossCents < 0 ||
discountCents > Number(catalog.unit_price_cents) discountGrossCents > grossUnitPriceCents
) { ) {
throw new AppError(400, 'POS_INVALID_DISCOUNT', 'El descuento de línea no es válido'); throw new AppError(400, 'POS_INVALID_DISCOUNT', 'El descuento de línea no es válido');
} }
if (!lineDiscountsEnabled && discountCents > 0) { if (!lineDiscountsEnabled && discountGrossCents > 0) {
throw new AppError( throw new AppError(
403, 403,
'POS_DISCOUNTS_DISABLED', 'POS_DISCOUNTS_DISABLED',
'Los descuentos están desactivados en este terminal', 'Los descuentos están desactivados en este terminal',
); );
} }
const stock = await client.query<{ available: number }>( // POS-FIX-12: TPV does not block on inventory_stock.
// Items are scanned physically (real stock). inventory_movements still records the sale,
// but inventory_stock.available is allowed to drift and may go negative.
await client.query(
`SELECT available FROM inventory_stock `SELECT available FROM inventory_stock
WHERE variant_id = $1 AND store_id = $2 WHERE variant_id = $1 AND store_id = $2
FOR UPDATE`, FOR UPDATE`,
[catalog.variant_id, session.store_id], [catalog.variant_id, session.store_id],
); );
if (Number(stock.rows[0]?.available ?? 0) < inputItem.quantity) { const netUnitPriceCents =
throw new AppError( catalog.offer_cents === null
409, ? Number(catalog.net_unit_amount_cents)
'POS_STOCK_UNAVAILABLE', : netFromGross(grossUnitPriceCents, catalog.vat_rate);
`Stock insuficiente para ${catalog.name}`, const discountedGrossUnitCents = grossUnitPriceCents - discountGrossCents;
); const discountedNetUnitCents = netFromGross(discountedGrossUnitCents, catalog.vat_rate);
} const discountNetCents = Math.max(0, netUnitPriceCents - discountedNetUnitCents);
const taxUnitCents = discountedGrossUnitCents - discountedNetUnitCents;
subtotalCents += grossUnitPriceCents * inputItem.quantity;
discountCents += discountGrossCents * inputItem.quantity;
taxCents += taxUnitCents * inputItem.quantity;
items.push({ items.push({
kind: 'stock', kind: 'stock',
variantId: catalog.variant_id, variantId: catalog.variant_id,
@@ -233,24 +271,15 @@ export class CreatePosSaleUseCase {
sku: catalog.sku, sku: catalog.sku,
ean: catalog.ean, ean: catalog.ean,
name: catalog.name, name: catalog.name,
unitPriceCents: Number(catalog.unit_price_cents), unitPriceCents: netUnitPriceCents,
discountCents, discountCents: discountNetCents,
taxCents: 0, taxCents: taxUnitCents,
quantity: inputItem.quantity, quantity: inputItem.quantity,
vatRate: catalog.vat_rate, vatRate: catalog.vat_rate,
}); });
} }
const subtotalCents = items.reduce( const totalCents = subtotalCents - discountCents;
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
const discountCents = items.reduce(
(sum, item) => sum + item.discountCents * item.quantity,
0,
);
const taxCents = items.reduce((sum, item) => sum + item.taxCents * item.quantity, 0);
const totalCents = subtotalCents - discountCents + taxCents;
if (totalCents <= 0) if (totalCents <= 0)
throw new AppError(400, 'POS_INVALID_TOTAL', 'El total de la venta debe ser positivo'); throw new AppError(400, 'POS_INVALID_TOTAL', 'El total de la venta debe ser positivo');
@@ -405,6 +434,47 @@ export class CreatePosSaleUseCase {
WHERE id = $1`, WHERE id = $1`,
[order.id], [order.id],
); );
// ── Club cashback accumulation ────────────────────────────────────────────
if (input.clubMemberId) {
try {
const settingsRow = await client.query<{ value: string }>(
`SELECT value FROM store_settings WHERE key = 'club_enabled' LIMIT 1`,
);
if (settingsRow.rows[0]?.value === 'true') {
const cashbackRow = await client.query<{ value: string }>(
`SELECT value FROM store_settings WHERE key = 'club_cashback_bps' LIMIT 1`,
);
const cashbackBps = Number(cashbackRow.rows[0]?.value ?? '0');
if (cashbackBps > 0) {
clubEarnedCents = Math.round((totalCents * cashbackBps) / 10_000);
if (clubEarnedCents > 0) {
const memberRow = await client.query<{ member_code: string }>(
`SELECT member_code FROM club_members WHERE id = $1 AND status = 'active' LIMIT 1`,
[input.clubMemberId],
);
clubMemberCode = memberRow.rows[0]?.member_code;
await client.query(
`INSERT INTO club_transactions (
member_id, sale_id, type, amount_cents, balance_delta_cents, idempotency_key, metadata
) VALUES ($1, $2, 'earn', $3, $4, $5, $6::jsonb)
ON CONFLICT (idempotency_key) DO NOTHING`,
[
input.clubMemberId,
order.id,
totalCents,
clubEarnedCents,
`club-earn-${order.id}`,
JSON.stringify({ source: 'pos', orderState }),
],
);
}
}
}
} catch {
// Club cashback failure must not block the sale.
}
}
} }
if (cashAppliedCents > 0) { if (cashAppliedCents > 0) {
@@ -432,6 +502,8 @@ export class CreatePosSaleUseCase {
payments: paymentResults, payments: paymentResults,
receipt, receipt,
createdAt: order.created_at, createdAt: order.created_at,
clubEarnedCents,
...(clubMemberCode ? { clubMemberCode } : {}),
}; };
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -483,6 +555,14 @@ export class CreatePosSaleUseCase {
vatRate: null, vatRate: null,
})); }));
const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0); const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
// Load club earned cashback from ledger if any.
const clubTxRow = await this.pool.query<{ balance_delta_cents: number }>(
`SELECT balance_delta_cents FROM club_transactions
WHERE sale_id = $1 AND type = 'earn' AND idempotency_key = $2
LIMIT 1`,
[orderId, `club-earn-${orderId}`],
);
const clubEarnedCents = clubTxRow.rows[0]?.balance_delta_cents ?? 0;
return { return {
orderId, orderId,
idempotencyKey, idempotencyKey,
@@ -496,6 +576,7 @@ export class CreatePosSaleUseCase {
payments, payments,
receipt, receipt,
createdAt: receipt.issuedAt, createdAt: receipt.issuedAt,
clubEarnedCents,
}; };
} }
} }

View File

@@ -241,6 +241,7 @@ export class ReceiveRestPaymentUseCase {
payments: paymentResults, payments: paymentResults,
receipt, receipt,
createdAt: new Date(), createdAt: new Date(),
clubEarnedCents: 0,
}; };
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');

Some files were not shown because too many files have changed in this diff Show More