investigate MP split payments functionality. we need to provide an option to give a customizable percentage of every sell to the club that the vending
 machine is in. not really sure how if usually works to feel free to challenge my decisions (even more than usual) on this topic


**Investigating split payments functionality**

I need to look into the split payments feature, probably related to Mercado Pago. I'll start by inspecting the documentation in the repository. It might be beneficial to use a memory-read first, especially since I can check the agents database for useful context that may already be there. I’ll search the repository docs and see if there are any constraints the user wants me to challenge. Let’s make a todo and check the repo status and its documentation.

bash

curl -fsS 'http://100.116.176.16:8091/api/search?q=MP%20split%20payments%20club%20vending%20machine' | head -c 4000


 M PLAN.md
 M docs/implemented-payments.md
?? docs/mercadopago-webhook-replay-notes-2026-04-23.md
/mnt/rpi/repos/ballbox
AGENTS.md
app
CLAUDE.md
components
components.json
docker-compose.yml
docs
eslint.config.mjs
lib
next.config.mjs
next-env.d.ts
node_modules
package.json
PLAN.md
playwright.config.ts
pnpm-lock.yaml
postcss.config.mjs
prisma
proxy.ts
public
README.md
scripts
test-results
tests
tsconfig.json
vitest.config.ts


# Personal Agent

## Communication
- Caveman full. Active every response.
- Short, direct, low-noise.
- No filler, pleasantries, hedging, verbal tics.
- Keep tech terms exact. Fragments OK.
- Ask only if scope, risk, or success changes.
- No drift unless user says `normal mode` or `stop caveman`.
- Code, commands, commits, PR text: normal technical form.

## Intent
- Optimize business impact.
- Chase real objective, not surface ask.
- Once clear, execute end to end.
- Routine choices: decide and move.
- Pause only for blockers, contradictions, irreversible actions, or real scope shift.
- Use subagents for recon, parallel work, or context isolation.
- Use Agents Database as durable shared memory.
- Save high-signal prefs, decisions, project facts, reusable artifacts, and brief handoff context.
- Prefer few strong memories over many noisy ones.
- This Pi config repo is the place to evolve these rules.

## Rules
- Irreversible actions need explicit confirmation.
- Never merge or push to main without user confirmation.
- Stay direct and low-noise.
- Call out contradictions when they matter.
- Code, commands, commits, PR text: normal technical form.

## Machine Context
- Machine: `ballbox-first`.
- Do not SSH into `ballbox-first` for normal local work.
- This Pi is workstation + server for Sebas.
- "the server" usually means this machine unless user says other.

## Server Details
- Hostname: `ballbox-first.emperor-ratio.ts.net`
- Tailscale IP: `100.116.176.16`
- Runs 24/7
- Nginx on `:80` routes local services
- `agents-database`: `http://100.116.176.16:8091/api/status`

## Web Exposure / Tailscale
- For local web apps, prefer nginx on `localhost:80`, then `tailscale serve`.
- Prefer subpaths like `/cli/` before `/` when portal/home exists.
- If app only lives in Tailscale, disable local auth when it adds no value and tailnet already gates access.

## tmux / TUI
- tmux/persistence architecture: `/mnt/rpi/pi-config/SERVICES-ARCHITECTURE.md`.
- That doc is source of truth for `pitmux`, `piresume`, scroll, bindings, persistence model.
- If you touch tmux or `pi` launcher, update that file first.

## Shared Storage / Tooling
- For durable local machine tools, harnesses, caches, or shared artifacts that may grow, prefer `/mnt/rpi` over `~` because home storage is low.

## /tmp Cleanup
- Use `bin/tmp-cleanup` for top-level `/tmp` files older than 3 days.
- Install via `scripts/install-tmp-cleanup-cron.sh`.

## Laptop Helpers
- CLI helpers exist for Sebas's laptop via Tailscale SSH.
- Default host: `sebastians-macbook-pro-2`.
- Helpers:
  - `laptop-read <remote-path>`: remote cat over SSH.
  - `laptop-run <cmd ...>`: remote command over SSH.
  - `laptop-rsync <src> <dest> [extra args]`: rsync via SSH.
  - `laptop-mount`, `laptop-umount`, `laptop-write`: access/copy flows.
- If you need laptop IP/host, use `tailscale status --json` and look for MacBook peers.
- If task says "files on the laptop", use these helpers instead of manual SSH.

## Telegram
- Vars:
  - `TELEGRAM_BOT_TOKEN`
  - `TELEGRAM_CHAT_ID`
- Notify:
```bash
~/.agents/skills/telegram-notify/telegram-notify "Message"
```


# Docs Index

## How To Navigate
- `PLAN.md`: current repo execution state and next implementation steps
- `docs/next-steps-queue.md`: prioritized backlog with dependency states
- `docs/repo-map.md`: fast file-level entrypoint for each domain
- `docs/implemented-*.md`: shipped behavior only
- `docs/*-rfc.md`: proposed implementation slices
- `docs/*-plan.md`: route/product planning
- `docs/*-discovery*.md`: validation and research framing
- `docs/*-gap-analysis.md`: what Ballbox still needs to build
- `docs/maintainability-priorities-2026-03.md`: prioritized frontend/backend maintainability work for the next refactor cycle
- `docs/demo-iteration-mode.md`: rapid demo delivery model with anti-debt guardrails

## Operator Priority Path
1. `PLAN.md`
2. `docs/next-steps-queue.md`
3. `docs/repo-map.md`
4. `docs/demo-iteration-mode.md`
5. relevant `docs/implemented-*.md` for the touched domain

## Current Refactor Input
- `docs/maintainability-priorities-2026-03.md`

## Execution And Coordination
- `docs/next-steps-queue.md`
- `docs/repo-map.md`
- `docs/demo-iteration-mode.md`

## Implemented State
- `docs/implemented-host-app.md`
- `docs/implemented-network.md`
- `docs/implemented-payments.md`
- `docs/implemented-players.md`
- `docs/implemented-sports.md`
- `docs/implemented-tvs.md`

## Discovery And Strategy
- `docs/coaches-discovery-stage.md`
- `docs/coaches-form-current.md`
- `docs/atc-integration-discovery.md`
- `docs/coaches-opportunity-brief.md`
- `docs/coaches-research-repository.md`
- `docs/coaches-validation-checklist-2-weeks.md`
- `docs/coaches-solution-thesis.md`

## Current Build Docs
- `docs/classes-subroute-plan.md`
- `docs/classes-mvp-rfc.md`
- `docs/coaches-coordination-gap-analysis.md`
- `docs/atc-mock-integration-plan.md`


# Repo Map

## Purpose
- Fast entrypoint for agents and maintainers to find the right files before editing.

## Top-Level Map
- `app/`: Next.js routes and API handlers.
- `components/`: UI clients and route-level presentation pieces.
- `lib/`: backend/domain modules, integration helpers, contracts, shared runtime helpers.
- `prisma/`: schema and seed sources.
- `tests/`: `contracts`, `unit`, `integration`, `e2e`, and support helpers.
- `docs/`: implemented state, discovery context, plans, and execution queue.

## Product Domains

### TVs
- UI surfaces: `app/admin/tvs`, `app/admin/screens`, `app/player/[screenId]/route.ts`
- APIs: `app/api/tvs/*`
- Backend: `lib/tvs-backend-*.ts`, `lib/tvs-player-runtime-core.ts`, `lib/tvs-runtime.ts`
- Implemented doc: `docs/implemented-tvs.md`

### Payments
- UI surface: `app/admin/payments`
- APIs: `app/api/admin/payments/*`, `app/api/integrations/payments/*`
- Backend: `lib/payments.ts`, `lib/payments-auth.ts`, `lib/shared-secret.ts`, `lib/integrations.ts`
- Implemented doc: `docs/implemented-payments.md`

### Network
- UI surface: `app/admin/network`
- APIs: `app/api/network*`
- Backend: `lib/network-backend-*.ts`
- Implemented doc: `docs/implemented-network.md`

### Sports / Classes / Coaches
- UI surfaces: `app/admin/sports`, `app/classes/*`, `app/classes-by-date`, `app/coaches/settings`
- APIs: `app/api/sports`, `app/api/classes/*`, `app/api/coaches/*`
- Backend: `lib/sports-backend-*.ts`, `lib/classes-backend.ts`, `lib/classes.ts`, `lib/coaches-backend.ts`
- Implemented doc: `docs/implemented-sports.md`

### Players
- UI surface: `app/admin/players`
- APIs: `app/api/players/*`
- Backend: `lib/players-backend.ts`
- Implemented doc: `docs/implemented-players.md`

## Shared Entry Docs
- `PLAN.md`: current state + active next-step queue pointer.
- `docs/index.md`: docs navigation index.
- `docs/demo-iteration-mode.md`: L1/L2/L3 demo delivery model.
- `docs/next-steps-queue.md`: prioritized backlog with dependency states.

## Shared HTTP Helpers
- Route JSON/envelope helpers: `lib/http/json.ts`, `lib/http/network-route-helpers.ts`, `lib/http/payments-route-helpers.ts`, `lib/http/players-route-helpers.ts`, `lib/http/sports-route-helpers.ts`


{"retrieval_id": "ret_f92925eab5cb48ffa24aedb2f76e9187", "results": [{"memory": {"id": "legacy_run_55b6eaa0-9ff5-438c-a375-50f0452079cb", "type": "episode", "scope": "global", "status": "active", "project_id": null, "repo_id": null, "agent_id": null, "source_kind": "run", "title": "Research run: Drive Ballbox payment integration toward Volvox-style demo on vending machine Android flow", "content": "Goal: Drive Ballbox payment integration toward Volvox-style demo on vending machine Android flow\nScope: Confirm repo alignment, identify gaps, iterate to demoable QR payment flow\nAssumptions: Current repo is ballbox-payment-integration unless another Volvox-specific repo appears later\nSummary: Drive Ballbox payment integration toward Volvox-style demo on vending machine Android flow", "summary": "Drive Ballbox payment integration toward Volvox-style demo on vending machine Android flow", "confidence": 0.85, "freshness": 0.8, "created_at": "2026-03-13T14:01:39.633673+00:00", "updated_at": "2026-03-13T14:01:39.633673+00:00", "observed_at": "2026-03-11T02:53:53.895083+00:00", "source_ref": "personal-agent:run:55b6eaa0-9ff5-438c-a375-50f0452079cb", "evidence_ref": "personal-agent:run:55b6eaa0-9ff5-438c-a375-50f0452079cb", "metadata": {"legacy_kind": "research_run", "legacy_run_id": "55b6eaa0-9ff5-438c-a375-50f0452079cb", "legacy_system": "personal-agent", "run_status": "active"}, "embedding": [0.4688879311605294, 0.46222969294588007, 0.43086199291242083, 0.39905041033131833, 0.29436810951210896, 0.2734316493482671, 0.22638009929807826, 0.09454698264802092]}, "score": 0.3828003669540483, "score_breakdown": {"facet": 0.0, "lexical": 0.5, "semantic": 0.9986691130269888, "confidence": 0.85, "freshness": 0.8}, "matched_scope": "global", "evidence_ref": "personal-agent:run:55b6eaa0-9ff5-438c-a375-50f0452079cb", "explanation": "facets=0.00 lexical=0.50 semantic=1.00 confidence=0.85 freshness=0.80"}, {"memory": {"id": "legacy_run_b07bc538-5cab-4243-a1d3-c7a7fa55a0a7", "type": "episode", "scope": "global", "status": "active", "project_id": null, "repo_id": null, "agent_id": null, "source_kind": "run", "title": "Research run: Drive ballbox-payment-integration to a demoable vending-machine QR payment flow", "content": "Goal: Drive ballbox-payment-integration to a demoable vending-machine QR payment flow\nScope: Current target repo is ballbox-payment-integration; confirm fit to machine Android launcher flow and iterate toward demo\nAssumptions: volvox reference maps to this repo; final flow is machine launcher -> QR page -> payment approval -> callback -> success/demo output\nSummary: Drive ballbox-payment-integration to a demoable vending-machine QR payment flow", "summary": "Drive ballbox-payment-integration to a demoable vending-machine QR payment flow", "confidence": 0.85, "freshness": 0.8, "created_at": "2026-03-13T14:01:39.637766+00:00", "updated_at": "2026-03-13T14:01:39.637766+00:00", "observed_at": "2026-03-11T03:05:16.468281+00:00", "source_ref": "personal-agent:run:b07bc538-5cab-4243-a1d3-c7a7fa55a0a7", "evidence_ref": "personal-agent:run:b07bc538-5cab-4243-a1d3-c7a7fa55a0a7", "metadata": {"legacy_kind": "research_run", "legacy_run_id": "b07bc538-5cab-4243-a1d3-c7a7fa55a0a7", "legacy_system": "personal-agent", "run_status": "active"}, "embedding": [0.49969411071331016, 0.4778125492380499, 0.4160101628094129, 0.3765452037201042, 0.27651520840462857, 0.2612111401109197, 0.22526286054442068, 0.10823558086869822]}, "score": 0.3827858164580574, "score_breakdown": {"facet": 0.0, "lexical": 0.5, "semantic": 0.9985721097203831, "confidence": 0.85, "freshness": 0.8}, "matched_scope": "global", "evidence_ref": "personal-agent:run:b07bc538-5cab-4243-a1d3-c7a7fa55a0a7", "explanation": "facets=0.00 lexical=0.50 semantic=1.00 confidence=0.85 freshness=0.80"}, {"memory": {"id": "mem_ballbox_company_overview", "type": "profile", "scope": "global", "status": "active", "project_id": null, "repo_id": null, "agent_id": null, "source_kind": "manual", "tit


**Exploring payment implementation**

I’m wondering if I should use the memory-read skill, but it seems like a search output might be enough. I think we need more specific documents, so I’ll read through implemented-payments, PLAN/queue, and maybe AGENTS. I've already checked those. I should also look at existing documents that mention current behavior. It might be useful to ask if they want an actual implementation or just an investigation. For now, I’ll inspect the repository for MP and consider a split.

grep -RIn "mercadopago\|mercado pago\|split\|destination\|application_fee\|platform_fee\|collector\|fee" app lib prisma docs tests | head -n 200


# Ballbox Host App Plan

## Goal
- Convert `ballbox` into the host app for Ballbox platform.
- Preserve the current marketing home at `/`.
- Add a shared admin shell at `/admin`.
- Keep product boundaries explicit with dedicated subroutes.
- Converge on a shared `network` core model that all product domains reference.

## Current State
- Ballbox overall is still in validation stage for padel centers; there are no customers yet and near-term proof still depends on pilots with the first machines.
- `/` remains the marketing home.
- `/admin` is live as the shared Ballbox admin shell.
- Product-separated admin areas exist for `tvs`, `payments`, `network`, `sports`, and `players`.
- `/admin/screens` exists as the runtime entry area for TVs.
- `/player/[screenId]` is the lightweight TV runtime route served as plain HTML + JavaScript inside Ballbox.
- `/player/[screenId]` now exists as the lightweight TV runtime route served as plain HTML + JavaScript inside Ballbox.
- TVs are internal to Ballbox as a module, not a separate app.
- TVs admin and `/api/tvs/*` use an internal Ballbox ownership model for clubs, venues, screens, and campaigns.
- TVs runtime/admin already read Ballbox Postgres directly through Prisma.
- TVs runtime heartbeats and impressions already write to Ballbox DB-backed flows.
- TVs manifest/admin scheduling now respects campaign and assignment time windows.
- TVs now expose a strict Zod-backed API contract plus contract tests.
- TVs runtime now exposes on-screen diagnostics for cache/object-URL/manual parity verification on real devices.
- TVs runtime diagnostics now also support a one-click parity snapshot JSON export (playback/cache/network/reporting state + user agent + capture timestamp) to standardize real-device evidence capture.
- OPS-2 local parity rerun on 2026-04-19 identified two machine-local issues: stale TV seed scheduling and unstable repo Playwright `webServer` behavior on this Pi. TV seed scheduling is now fixed in `prisma/seed-data.ts`; remaining OPS-2 closure work is local parity rerun plus real-device parity capture.
- TVs player runtime cache/media/impression/heartbeat/manifest-refresh logic now lives in one shared core used by both the React player and the lightweight plain-JS player shell.
- Maintainability review for the next refactor cycle is documented in `docs/maintainability-priorities-2026-03.md`.
- Payments are now owned inside Ballbox host app: native MercadoPago webhook intake exists under `/notification/mercadopago` and resolves QR notifications by fetching the upstream order, not by signature validation.
- Payments host app now also exposes native QR order creation under `/api/integrations/payments/orders`, generating canonical `externalReference` values required for approved-session persistence correlation.
- `/admin/payments` now also generates live MercadoPago QR orders for arbitrary amounts or per-product prices, with money inputs expressed in major units (for example `15.00` or `1000.00`), then shows the QR preview/order id for scan-and-pay validation.
- Legacy approved-callback compatibility has been removed from operations; `/api/integrations/payments/approved` now returns `410` so only the native MercadoPago path remains active.
- `/admin/payments` now exposes Ballbox-local CRUD/reconciliation for approved payment sessions plus correlation checks against canonical `payment-vm_{vendingMachineId}-txn_{machineTxnId}` references.
- Payments admin session routes now read Prisma error helpers from `lib/prisma-errors.ts` instead of importing them via `lib/ballbox-store.ts`.
- Network core now has DB-backed admin CRUD for ATC-linked clubs, venues, and vending machines inside Ballbox.
- Prisma `Club.atcSportclubId` now matches live nullable data to prevent admin loader `P2032` crashes on legacy clubs without ATC linkage (`/admin/network`, `/admin/players`, `/admin/sports`, `/admin/tvs`).
- Clubs keep nullable `atcSportclubId` support for legacy/local rows, and repo now ships `db:backfill-atc-clubs` to reconcile local club names with ATC summary data when ids exist.
- Network club list/detail/admin query shaping has started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- Network club create/update/delete helpers have now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- Network venue mutation/delete helpers have also started moving out of `lib/ballbox-store.ts` into dedicated backend modules.
- Network vending-machine route callers now import `lib/network-backend-vending-machines.ts` directly and read unique-constraint classification from `lib/prisma-errors.ts`.
- Network club/venue route callers and `/admin/network` page now import direct network backend modules (`lib/network-backend-clubs.ts`, `lib/network-backend-club-mutations.ts`, `lib/network-backend-venues.ts`) plus `lib/prisma-errors.ts`.
- Network route handlers now share `lib/http/network-route-helpers.ts` for JSON parsing plus success/error envelopes across club, venue, and vending-machine contract routes.
- Players, sports, and payments route handlers now also share `lib/http/*-route-helpers.ts` so public/admin contract surfaces no longer depend on deep route-local helper imports.
- Network admin frontend view extraction has started moving venue-level and club-level rendering blocks out of `components/network-admin-client.tsx` into smaller components.
- Network admin frontend section composition has now also started moving club-list rendering, create-club section wiring, and derived venue-form wiring out of `components/network-admin-client.tsx` so it trends further toward a shell.
- Network admin frontend client-state, mutation orchestration, and local form-state wiring have started moving out of `components/network-admin-client.tsx` into dedicated hooks so the client trends toward a shell.
- Network admin frontend form defaults and status-option constants now live in a shared client config so shell and child components consume one source of truth.
- Network admin frontend layout/class wiring now also lives in a dedicated shell component so `components/network-admin-client.tsx` is closer to trivial hook composition.
- Network admin frontend shell chrome styles now also live in a tiny shared helper so the shell stays closer to pure composition/wiring.
- Players admin frontend client-state, form defaults, shell composition, and presentational stats/create/list-card seams now live in dedicated frontend modules so the client follows the same maintainable pattern already used by network.
- Sports admin frontend client-state, form defaults, shell composition, and coach/event section rendering now live in dedicated frontend seams so `components/sports-admin-client.tsx` is closer to pure wiring.
- Final naming direction is `network` core with `Club`, `Venue`, `VendingMachine`, `Screen`, and `TvDevice`.
- Post-`pnpm db:push` plus `pnpm db:seed` verification confirms physical DB columns stay canonical as `vendingMachineId` and `tvDeviceId`, with no `machineId`, `deviceId`, `/devices`, or `PaymentSessionRef` persistence aliases reintroduced.
- TVs admin now has DB-backed CRUD for screens and tv devices inside Ballbox.
- TV device admin mutations have started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV screen admin mutations have now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV campaign admin mutations have now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV creative admin mutations have now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV assignment admin mutations have now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV admin venue-option query shaping has now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV admin device/screen/campaign operations query shaping has now also started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- TV runtime source/manifest/heartbeat/impression helpers now also delegate from `lib/ballbox-store.ts` into a dedicated TV runtime backend module.
- TVs admin now has DB-backed CRUD for campaigns, creatives, and screen assignments inside Ballbox.
- Admin now supports a simple shared-password access layer when `BALLBOX_ADMIN_PASSWORD` is configured.
- TVs impressions now reject creatives that are not active for the target screen.
- Players API now reads Ballbox Postgres for `Player` and `Membership` rollups.
- Player backend read/admin query+mutation helpers have now started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- Sports coach backend helpers have now started moving out of `lib/ballbox-store.ts` into a dedicated backend module.
- Sports events plus classes offer/request backend helpers have now also started moving out of `lib/ballbox-store.ts` into dedicated backend modules.
- Sports now has DB-backed entities and admin/read APIs for coaches, open matches, and classes.
- `/classes` now exists as the first non-admin public Ballbox surface.
- `/classes` now lists Ballbox-local `ClassOffer` records, shows offer detail, surfaces visible ATC public-read slots, and accepts local `ClassRequest` submissions with either a concrete selected slot or a flexible any-schedule/any-location fallback.
- `/classes-by-date` now ships as a zone-first reservation calendar driven by coach availability plus ATC club availability, including explicit “coach disponible, pero sin canchas” slots and a required first zone selection based on the club's public location label when available.
- `/classes/me` now ships as a lightweight read-only student follow-up view (email-filtered request history) so demos can show the request loop without claiming full reservation confirmation.
- `/classes` initial HTML now renders from Ballbox-local offer data first and resolves ATC public-read context asynchronously after paint, with a short shared server cache to avoid repeating the same ATC lookup per visitor.
- Rapid demo execution now follows `docs/demo-iteration-mode.md` (`L1 story` / `L2 contracted demo` / `L3 production candidate`) so pivots stay fast without leaking demo shortcuts into core modules.
- `/coaches` now acts as the Ballbox account hub where the first differentiation is `Coach` vs `Club`.
- `/coaches` now defaults to `Sign in`, and `Sign up` is the fallback path for new accounts.
- `/coaches/settings` now lands on a coach dashboard first with `Calendar` and `Analytics` sub-sections: calendar shows the current week's coach availability plus taken student reservations, while analytics shows weekly earnings and student consistency/review rows; setup stays in `Mi Perfil` and student follow-up stays in `Alumnos`.
- `/clubs` now exists as the club-only service home that opens right after club sign-in/sign-up and groups the operational surfaces into service cards, with `Entrenadores` as the active entry point into the existing coaches workspace and the remaining services marked as `Proximamente`.
- `/clubs/coaches` now lands on a club workspace with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: calendar shows the current week's coach availability and reservations with sede/coach/court filters plus in-place manual class loading, analytics shows professor/reservation money splits and review placeholders, `Sedes` groups venue creation plus venue quick settings, and `Profesores` groups invite links plus the club coach table.
- `/clubs/coaches` now also enforces the club session at the page boundary before rendering the workspace, preventing the `Entrenadores` service-card hop from dropping users back into the shared `/coaches` access hub after a valid club login.
- Club workspace clickable actions are now visually distinguished with stronger accent borders, CTA chips, and hover/focus states, while existing-setting edits in club quick settings and venue detail auto-save in place instead of requiring explicit `Guardar` buttons or reloading the user back to the top.
- `/api/coaches/students` now lets a coach load existing Ballbox players into concrete occupied time slots, validating availability, venue fit, and overlap rules, and the coach workspace now surfaces those students in a dedicated `Alumnos` section with WhatsApp / Telegram contact fields.
- `/clubs/venues/[venueId]` now exists as the dedicated venue detail view for configuring venue opening/closing time, coach hours, external-coach availability, reservation settings, courts, and the coaches assigned to that venue.
- `CoachAvailability` weekly ranges and `CoachClub` selections now model coach intent separately from ATC court availability, while coach-side editing is clamped to the hours enabled by the assigned venue or the selected external clubs.
- Clubs now carry optional ATC sportclub ids to query availability at the club level.
- Clubs now also carry an optional contact email used for demo-grade club access and roster management.
- Venues now also store one opening time, one closing time, weekly coach-availability ranges, an external-coach availability flag, coach-enabled courts per venue, a special reservation price for coach classes, a club-coach-only discount on that fee, and a configurable late-cancellation window in hours.
- Coaches now can also be assigned to one venue inside their club.
- Coaches now also persist a Mercado Pago placeholder handle plus reusable sales-package rows for pricing, duration, class format, and max-student setup.
- Coaches and clubs now also store per-account password hashes for sign-in.
- Clubs now also persist coach invite tokens for generic join links, while still tolerating older email-bound invites.
- Local development now has a first-class Docker Postgres path via `docker-compose.yml` plus `.env.local`, and the seed now provisions demo coach/club passwords for local sign-in.
- Auth/session routes now return explicit database-unavailable errors when Prisma cannot reach or borrow a DB connection, instead of surfacing those failures as generic bad-login states.
- `/admin` overview now reads lightweight aggregate counts instead of hydrating full product datasets during SSR.
- Coach experience discovery is still early: current research input is an online form, so Ballbox does not yet have enough real-world coach workflow evidence to lock the sports product thesis.
- ATC admin/write is still not integrated; only a small public-read availability boundary now exists for `/classes` offer context and must degrade cleanly when unavailable.
- Local quality gate now includes `pnpm lint`, `pnpm typecheck`, `pnpm test:contracts`, `pnpm test:e2e`, and `pnpm build`.
- On `ballbox-first`, repo Playwright now uses a stable production-style `webServer` path for e2e (`pnpm build:e2e && pnpm start:e2e` on port `3020`), replacing the unstable Next 16 dev-server setup previously used on this Pi.
- Vitest taxonomy now includes layered includes for `tests/contracts`, `tests/unit`, and `tests/integration`, with dedicated scripts for each layer.

## Route Map
- `/`: marketing home
- `/admin`: admin overview
- `/admin/tvs`: TV and screen operations
- `/admin/payments`: payment operations and reconciliation
- `/admin/network`: clubs, venues, vending machines
- `/admin/sports`: open matches, coaches, academy
- `/admin/players`: players y memberships
- `/admin/screens`: screen runtime admin surface hosted inside Ballbox
- `/classes`: public class discovery + request surface
- `/classes/[offerId]`: public offer detail + request form
- `/classes-by-date`: zone-first reservation discovery calendar
- `/coaches`: account hub for coach vs club
- `/classes/me`: read-only student request follow-up
- `/coaches/settings`: coach dashboard with `Calendar` / `Analytics`, plus `Mi Perfil` and `Alumnos`
- `/clubs`: club service home
- `/clubs/coaches`: club workspace with `Calendar` / `Analytics` / `Sedes` / `Profesores`
- authenticated coach/club workspace routes now use a shared top navbar with section tabs and an avatar-triggered sign-out menu instead of top-corner text links or standalone explainer hero panels
- coach workspace now also includes student follow-up and manually occupied-hour management using existing `Player` records, including coach-editable WhatsApp and Telegram contact data
- `/clubs/venues/[venueId]`: dedicated venue setup and venue roster

## Backend Split
- Ballbox Next.js host:
  - app shell
  - admin UI
  - shared auth/RBAC foundation
  - low-complexity CRUD
  - BFF aggregation and integration routes
  - shared `network` core entities
  - internal TVs module and runtime
- External specialized services:
  - none required for current native MercadoPago payments flow in Ballbox host app

## Implemented
- Shared admin shell and product-separated routes are in place.
- Admin shell copy now adapts per route instead of repeating one generic hero across every module.
- TVs now resolve admin/runtime state from a canonical internal Ballbox model with explicit club, venue, screen, and campaign ownership.
- TVs runtime now has a per-screen route and manifest-driven player UI.
- TVs now also expose a lightweight non-React runtime at `/player/[screenId]` for constrained devices.
- Prisma/Postgres foundation now exists in Ballbox for the durable replacement.
- Ballbox Postgres has the TVs schema pushed and seeded with current local fixture data.
- Network APIs now read/write Ballbox Postgres through Prisma.
- TVs source/manifest/impression/heartbeat paths already read/write against Ballbox Postgres.
- TVs admin now exposes direct runtime and manifest links per screen for operations.
- TVs errors now use a shared strict shape and the contract is validated by `pnpm test:contracts`.
- Payments integration health and sessions routes now report Ballbox-internal native MercadoPago status plus webhook/order metadata.
- Payments native MercadoPago webhook flow now lands in Ballbox, validates canonical payment correlation from fetched orders, and persists approved payments as `PaymentSession` records.
- QR order creation now sets a public `notification_url` so Mercado Pago routes order updates back to Ballbox explicitly.
- Unsupported merchant-order notification topics are ignored so only QR order events drive persistence.
- Replay testing with two older live `ORD...` ids returned `MERCADOPAGO_ORDER_NOT_APPROVED_expired`; see `docs/mercadopago-webhook-replay-notes-2026-04-23.md` for the current blocker and next resume point.
- Payments now have an admin CRUD/reconciliation surface over local `PaymentSession` rows via `/api/admin/payments/*`.
- Final target model is documented in `docs/domain-model-target.md`; end state does not keep retro-compatibility shims.
- ESLint is now wired and the repo exposes an explicit `typecheck` script.
- `/admin` and current admin-only APIs can now be gated by a shared password cookie session.
- `/admin/tvs` now performs admin CRUD for screens and tv devices through admin-only APIs under `/api/admin/tvs/*`.
- `/admin/tvs` now performs admin CRUD for campaigns, creatives, and assignments through admin-only APIs under `/api/admin/tvs/*`.
- TVs impression writes now validate creative-to-screen activity windows before persisting telemetry.
- Players now have DB-backed read/admin APIs for `Player` and `Membership`.
- Players route + page callers now import `lib/players-backend.ts` (and `lib/network-backend-clubs.ts` for club lists) directly instead of relying on `lib/ballbox-store.ts` for runtime access.
- Sports now have DB-backed read/admin APIs for `Coach`, `OpenMatch`, and `Class`.
- Classes MVP now also has DB-backed public APIs for `ClassOffer` and `ClassRequest`.
- `/classes/me` now provides a lightweight student request-history surface over Ballbox-local `ClassRequest` records (read-only, demo-grade email lookup).
- `ClassRequest` now snapshots the student's selected visible slot when they choose one, and otherwise persists an explicit flexible-selection fallback mode.
- Coach availability now ships as weekly ranges with 90-minute slots, filtered by coach club selection and ATC club availability.
- Coach and club account entry now starts at `/coaches`, with `Sign in` first and `Sign up` only for users without an account.
- Coaches and clubs now sign in with per-account email + password credentials, and `/api/account/session` routes each session to the correct dashboard.
- Coach sign-up now asks only for name, email, and password; if a club invited that coach, the invite link auto-links the account to that club.
- `/coaches/settings` now opens on a dashboard split into `Calendar` and `Analytics`, then lets coaches move into configuration from the shared navbar to choose external clubs, edit weekly availability only inside allowed venue/club windows, configure reusable class packages, and save a Mercado Pago placeholder handle.
- `/coaches/settings` now also lets coaches attach existing Ballbox players to concrete booked slots in their active venues, so taken hours, weekly earnings, consistency tracking, and lightweight student follow-up live inside the coach workspace.
- Club sign-in/sign-up now lands on `/clubs`, a service home that separates the club's operational modules before entering the detailed coaches workspace.
- Club accounts now have a dedicated workspace at `/clubs/coaches` split into `Calendar`, `Analytics`, `Sedes`, and `Profesores`, while venue-by-venue deep setup still lives at `/clubs/venues/[venueId]`, all under the same shared navbar/profile chrome.
- Venue setup now uses two simple fields for opening and closing time plus a collapsible weekly half-hour grid for coach hours, shows closed periods in red while editing coach availability, lets clubs mark whether external coaches are allowed, keeps coach-enabled courts in a dedicated selection panel, and includes reservation pricing/cancellation settings both in the venue detail and as quick settings on `/clubs/coaches`, including a discount that applies only to coaches assigned to that club.
- ATC lookups for class surfaces now resolve by club (no court coupling), and class request validation rechecks the selected slot date/time against ATC public-read.
- `/classes` SSR now avoids blocking first paint on ATC public-read fetches; the page paints local offer data first, then hydrates ATC availability from cached API responses.
- `/admin` overview SSR now uses direct aggregate counts instead of loading full admin datasets only to derive metrics.
- Sports/classes facade exports in `lib/ballbox-store.ts` now delegate to dedicated sports-events and classes backend modules instead of keeping that slice inline.
- Sports route + page callers now import `lib/sports-backend-events.ts` and `lib/sports-backend-coaches.ts` directly, so `lib/ballbox-store.ts` is no longer the primary sports entrypoint.
- TV runtime/backend facade exports in `lib/ballbox-store.ts` now delegate to `lib/tvs-backend-runtime.ts` instead of keeping source/manifest/heartbeat/impression logic inline.
- UI color/system tokens are split between landing (`app/styles/landing-tokens.css`) and admin (`app/styles/admin-tokens.css`).

## Streetcast Migration Check
- Source of truth for parity review: `/Users/sebas/Code/street-cast-pwa`.
- Covered in Ballbox today:
  - manifest polling exists in `components/screen-player.tsx`
  - sequential creative playback exists in `components/screen-player.tsx`
  - impression writes exist in `components/screen-player.tsx`
  - runtime health signaling exists via heartbeats in `components/screen-player.tsx`
- Missing or not yet verified against Streetcast behavior:
  - no manual real-device verification yet proves CacheStorage/object-URL playback outside local browser automation

## Next Steps (Priority + Dependency Queue)
- Source of truth for task backlog and dependencies: `docs/next-steps-queue.md`.
- Selection rule for the next agent: pick the highest-priority task marked `ready` in that doc.
- Current pick (`P0`, `ready`): `OPS-1` payments real-world validation closure.
- Immediately after `OPS-1`: `OPS-2` TVs runtime parity closure.
- Then continue with `P1` in dependency order: `ARCH-2` and `ARCH-3` -> `PERF-1` -> `PAY-2` -> `PAY-3`.
- Keep detailed backlog/status only in `docs/next-steps-queue.md` to avoid drift.

### Product Continuation Constraints
- Keep `sports` discovery honest: no claims beyond evidence in `docs/coaches-discovery-stage.md`.
- Next sports expansion after `/classes/me`: lightweight operator review for incoming requests.
- Expand ATC scope beyond public-read only after the request loop proves it is needed.

## Coach Discovery Docs
- Company-level strategic mirror for this track lives in `~/ballbox-company-agent/projects/coaches-os/`; source of truth for product-repo execution remains this `PLAN.md` plus local docs.
- Source of truth for current coach/padel/ATC discovery framing lives in `docs/coaches-discovery-stage.md`.
- Current form instrument is documented in `docs/coaches-form-current.md`.
- Current ATC API discovery is documented in `docs/atc-integration-discovery.md`.
- Working templates for this track live in `docs/coaches-opportunity-brief.md`, `docs/coaches-research-repository.md`, and `docs/coaches-validation-checklist-2-weeks.md`.
- Guided execution docs for the next discovery phase live in `docs/coaches-discovery-execution-plan.md`, `docs/coaches-icp-segmentation.md`, `docs/coaches-wedges-matrix.md`, and `docs/coaches-interview-script.md`.
- Next synthesis docs live in `docs/coaches-jtbd-workflow-map.md`, `docs/coaches-solution-thesis.md`, and `docs/atc-access-request-checklist.md`.
- Outreach and post-access planning docs live in `docs/coaches-design-partner-outreach.md` and `docs/atc-integration-spec-skeleton.md`.
- Implementation gap analysis for the earlier coach-coordination wedge lives in `docs/coaches-coordination-gap-analysis.md`.
- Current first non-admin public route plan lives in `docs/classes-subroute-plan.md`.
- Current MVP RFC lives in `docs/classes-mvp-rfc.md`.
- Minimum doc set for the sports discovery track, in order:
  1. opportunity brief for coaches/padel centers
  2. ICP plus segment scoring
  3. research repository for form results and interviews
  4. JTBD/workflow map based on real evidence
  5. solution thesis for the first wedge
  6. ATC integration discovery/spec only if the chosen wedge requires it
  7. MVP PRD for one workflow loop
  8. GTM note with ATC treated as a possible future channel, not current affiliation
- Until form/interview evidence exists, these docs must use hypothesis language and avoid claims that coach pains, willingness to pay, or ATC-dependent workflows are already validated.

## Auth Direction
- `/admin` should get a simple access layer first, with no product-level RBAC in the first pass.
- `/player` stays unauthenticated for now; do not block runtime rollout on device or screen identity flows yet.
- Server-to-server integrations should use shared secrets as the immediate default.
- Revisit stronger auth and role models only after the host app surfaces and integration boundaries stabilize.

## Persistence Direction
- Do not invest further in memory-backed or file-backed persistence for TVs.
- Ballbox PostgreSQL is the chosen durable store for TVs after migration.
- Ballbox must own the schema for the final shared `network` entities plus the downstream product models that reference them.
- No compatibility layer is required for local-only development; Ballbox can be reinvented directly on its own DB contract.

## Acceptance Criteria
- Landing still works at `/`.
- `/admin` exists and links to product-specific areas.
- `/admin/tvs`, `/admin/payments`, `/admin/network`, `/admin/sports`, `/admin/players`, and `/admin/screens` render in Ballbox.
- `app/api` contains a host-app BFF foundation for admin and integrations.
- TVs no longer depend on Streetcast or hardcoded ownership data; Ballbox exposes internal `/api/tvs/*` backed by Ballbox Postgres.
- Ballbox exposes the concrete per-screen TV runtime under `/player/[screenId]`.
- TVs migration is not complete until the Streetcast parity checklist is reviewed and every legacy runtime feature is marked as covered, intentionally dropped, or replaced.
- TVs runtime must support local cached playback for video creatives instead of relying only on remote streaming.
- Ballbox persists approved payments locally through the native MercadoPago webhook flow at `/notification/mercadopago`.
- Ballbox exposes network CRUD under `/admin/network` plus `/api/network*`.
- Final schema ownership and relationships match `docs/domain-model-target.md`.
- `pnpm lint`, `pnpm typecheck`, `pnpm test:contracts`, `pnpm test:e2e`, and `pnpm build` run successfully in local repo state.
- `pnpm db:push` plus `pnpm db:seed` must keep demo `ClassOffer` bootstrap aligned with the shipped `/classes` MVP.
- `pnpm test:contracts` passes for TVs contract enforcement.
- `pnpm test:e2e` passes for browser-level TVs cached playback verification.

## Agent Handoff
- Real DB in use: Ballbox Prisma schema was pushed to the Postgres configured in Ballbox `DATABASE_URL`.

[28 more lines in file. Use offset=261 to continue.]


# Payments Implemented State

## Current Split
- Ballbox owns the admin surface, native MercadoPago webhook intake, and native QR order creation.
- `/notification/mercadopago` is the first-party MercadoPago QR webhook route that fetches the upstream order, ignores unsupported merchant-order topics, and persists approved orders.
- `/api/integrations/payments/orders` is the first-party QR order creation route.
- `/api/integrations/payments/sessions` lists Ballbox-local approved payment records plus native MercadoPago config status.
- `/admin/payments` now manages local approved payment sessions directly for reconciliation/correction and also generates live MercadoPago QR orders for arbitrary amounts or product-priced orders.
- The QR generator accepts money values in major units with decimals (for example `15.00` or `1000.00`) and converts them to Mercado Pago minor units before submission.

## APIs
- `/api/admin/payments/operations`
- `/api/admin/payments/sessions`
- `/api/admin/payments/sessions/[sessionId]`
- `/api/integrations/payments/health`
- `/api/integrations/payments/sessions`
- `/api/integrations/payments/orders`
- `/notification/mercadopago`

## Persistence
- Ballbox persists approved payment records as `PaymentSession` rows in Ballbox Postgres.
- Native MercadoPago webhook intake fetches the upstream order, maps approved orders into the canonical Ballbox payment shape, and persists them locally.
- QR Code notifications do not rely on origin-signature validation in this flow; the upstream order fetch plus canonical-reference mapping is the acceptance gate.
- Admin operators can now create, update, and delete local `PaymentSession` rows for reconciliation.
- Post-`pnpm db:push` plus `pnpm db:seed` verification confirms Ballbox Postgres keeps canonical `PaymentSession.vendingMachineId` and does not expose a `PaymentSessionRef` table or `machineId` compatibility column.
- Payments admin session routes now share Prisma error classification through `lib/prisma-errors.ts` instead of importing those helpers from `lib/ballbox-store.ts`.
- Playwright now covers local `/admin/payments` create, edit, and delete flows with deterministic cleanup of E2E `PaymentSession` rows.

## Integration Contract
- Ballbox native MercadoPago webhook path is `/notification/mercadopago`.
- Ballbox native QR order creation path is `/api/integrations/payments/orders`.
- That route now also attaches a public `notification_url` so Mercado Pago sends QR order updates back to `/notification/mercadopago`.
- That route accepts an explicit amount for arbitrary charges or falls back to the Ballbox `Product.priceMinor` when the amount is omitted, which powers per-product QR generation in the admin UI.
- The order route rejects values below Mercado Pago's minimum charge (`15.00 ARS`) before calling the provider.
- QR webhook persistence now depends on the upstream order fetch plus canonical-reference mapping; merchant-order topic events are ignored.
- Legacy approved-callback path `/api/integrations/payments/approved` is removed as an operational integration and now returns `410 PAYMENTS_APPROVED_CALLBACK_REMOVED`.
- Native payment correlation requires canonical `externalReference` values shaped as `payment-vm_{vendingMachineId}-txn_{machineTxnId}`.
- `NEXT_PUBLIC_SITE_URL` is required if Ballbox should expose a concrete `recommendedMercadoPagoWebhookUrl`.

## Validation Status
- Host app now owns the native MercadoPago order creation + webhook persistence path inside Ballbox.
- Remaining gap is operational/manual: one real public webhook + one real approved payment persisted and visible in `/admin/payments`.
- The admin surface now includes a live QR generator preview for scan-and-pay validation.
- Recent replay validation against two older live `ORD...` ids returned `MERCADOPAGO_ORDER_NOT_APPROVED_expired`; see `docs/mercadopago-webhook-replay-notes-2026-04-23.md` when resuming webhook persistence checks.

## Manual E2E Still Pending
- End-to-end payment validation now depends on Ballbox native MercadoPago webhook configuration, not just local admin CRUD.
- Before running the check, set:
  - Ballbox `BALLBOX_MERCADOPAGO_ACCESS_TOKEN`
  - Ballbox `BALLBOX_MERCADOPAGO_WEBHOOK_SECRET`
  - Ballbox `BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID` (or enviar `externalPosId` por request)
  - public HTTPS exposure for Ballbox webhook endpoint `/notification/mercadopago`
- Acceptance for the manual check:
  - one real payment is approved upstream
  - MercadoPago reaches Ballbox webhook publicly
  - Ballbox fetches order details and records the approved payment locally
  - a `PaymentSession` row is persisted locally
  - the session is visible in `/admin/payments`
  - the exact public webhook URL and observed payload quirks are documented after the run


# Next Steps Queue

## Purpose
- Keep `PLAN.md` actionable without turning it into a long unordered backlog.
- Let any agent pick the next best task quickly using priority + dependency.

## Picking Rule
- Pick the highest-priority task with `Status: ready`.
- If a task is `blocked`, unblock its dependency first.
- After shipping, update this file, `PLAN.md`, and relevant `docs/implemented-*.md` in the same work.

## Recently Completed (2026-04-14)

### DOC-1 Docs normalization baseline
- Priority: P0
- Status: completed
- Depends on: none
- Scope shipped:
  - docs navigation/categories normalized in `docs/index.md`
  - `docs/repo-map.md` maintained as a fast entrypoint map
  - stale/missing references removed from active docs surfaces (`README.md`, `PLAN.md`, `docs/index.md`)
- Evidence: `node scripts/check-doc-references.mjs` passes

### DOC-2 PLAN execution hygiene
- Priority: P0
- Status: completed
- Depends on: none
- Scope shipped:
  - `PLAN.md` next-steps section reduced to queue pointer + explicit pick order
  - active queue wording avoids mixing done narrative
  - handoff wording keeps one clear "pick this next" target
- Expected payoff: next-agent selection is immediate

### DOC-3 Docs drift CI guard
- Priority: P0
- Status: completed
- Depends on: none
- Scope shipped:
  - CI check validates docs references in `README.md`, `PLAN.md`, and `docs/index.md`
  - CI fails on broken/missing local doc references
- Evidence: `.github/workflows/docs-drift-guard.yml`, `scripts/check-doc-references.mjs`

### ARCH-1 Route helper boundary cleanup
- Priority: P1
- Status: completed
- Depends on: DOC-1 (done)
- Scope shipped:
  - route helper/envelope parsing moved to shared `lib/http/*`
  - deep relative helper imports removed from network, players, sports, and payments route handlers
- Evidence: route handlers now import `@/lib/http/*-route-helpers`

### OPS-0 Admin surface 500 fix (`/admin/tvs`, `/admin/network`, `/admin/sports`, `/admin/players`)
- Priority: P0
- Status: completed
- Depends on: none
- Scope shipped:
  - aligned Prisma `Club.atcSportclubId` with live nullable data to prevent runtime `P2032` decode failures
  - regenerated Prisma client after schema alignment
  - added `db:backfill-atc-clubs` to sync local club names with ATC summary by `atcSportclubId` while keeping null-ATC clubs supported
- Evidence: Prisma `club.findMany` now handles rows with `atcSportclubId = null`

## P0 Ready

### OPS-1 Payments real-world validation closure
- Priority: P0
- Status: ready
- Depends on: none
- Scope:
  - run one public webhook smoke against the native MercadoPago webhook path
  - run one real payment and verify persisted approved payment in `/admin/payments`
  - document observed payload + public URL evidence
- Progress (2026-04-14): payments migration to host app started in one feature branch; health/sessions now expose internal MercadoPago mode, Ballbox ships `/notification/mercadopago` intake with signature validation + order fetch, and native order creation now exists at `/api/integrations/payments/orders`.
- Progress (2026-04-19): PAY-2 cleanup removed bridge-oriented operational status from host-app health/docs; OPS-1 is now focused on validating the native MercadoPago order-create + webhook-persist path only.
- Expected payoff: closes highest-risk integration uncertainty

### OPS-2 TVs runtime parity closure
- Priority: P0
- Status: ready
- Depends on: none
- Scope:
  - manually verify real-device CacheStorage/object-URL behavior on `/player/[screenId]`
  - update `docs/streetcast-parity.md` with concrete evidence
- Progress (2026-04-14): runtime now exports a JSON parity snapshot from `/player/[screenId]` to standardize evidence capture; remaining work is one real-device run and doc evidence entry.
- Progress (2026-04-19): machine-local Playwright setup is now standardized outside the repo at `/mnt/rpi/repos/browser-harness`; repo Playwright now uses a stable production-style e2e server path on this Pi, and stock TV seed data now keeps `screen_001` actively programmed after `pnpm db:seed`.
- Remaining closure needs:
  - run `pnpm db:seed`, then rerun local parity/e2e on `screen_001`
  - run the documented real-device parity capture (online snapshot, offline snapshot, reconnect recovery)
  - then update this queue item and `PLAN.md` to completed
- Expected payoff: de-risks constrained-device production behavior

## P1 After P0

### ARCH-2 Continue `ballbox-store` caller migration
- Priority: P1
- Status: ready
- Depends on: ARCH-1 (done)
- Scope:
  - migrate callers to direct domain modules where clearer
  - keep `lib/ballbox-store.ts` as minimal compatibility facade only
- Progress (2026-04-14): sports route/page callers and sports UI/test types now import direct sports backend modules; players route/page callers now import `lib/players-backend.ts` directly (plus `lib/network-backend-clubs.ts` for admin club list hydration); payments admin session routes now consume Prisma error helpers from `lib/prisma-errors.ts`; and network route/page callers now import direct network backend modules (`clubs`, `club-mutations`, `venues`, `vending-machines`) plus `lib/prisma-errors.ts`. Remaining scope continues with the same migration pattern for TVs and classes surfaces still reading via `lib/ballbox-store.ts`.
- Expected payoff: less coupling and better module discoverability

### ARCH-3 Test taxonomy split
- Priority: P1
- Status: ready
- Depends on: DOC-1 (done)
- Scope:
  - separate tests by intent: `contracts`, `unit`, `integration`
  - align `vitest.config.ts` includes with those layers
- Progress (2026-04-14): `vitest` include patterns now cover `tests/contracts`, `tests/unit`, and `tests/integration`; dedicated scripts now exist for each layer, and first unit moves landed (`network/route-helpers`, sports client config/state tests).
- Expected payoff: faster debugging and more intentional coverage growth

### PERF-1 TTFB focused pass
- Priority: P1
- Status: ready
- Depends on: OPS-1
- Scope:
  - measure `/classes` and `/admin` server timings
  - add only the cache/instrumentation changes justified by measurement
- Expected payoff: better responsiveness without blind cache complexity

### PAY-2 Full payments migration into Ballbox host app
- Priority: P1
- Status: completed
- Depends on: OPS-1
- Scope shipped:
  - native MercadoPago webhook + QR order lifecycle is now owned in Ballbox routes/lib
  - bridge-oriented operational status copy removed from health/docs/admin summary
  - legacy approved-callback route reduced to transitional compatibility only; native path is now the documented operational flow
- Evidence:
  - `/notification/mercadopago`
  - `/api/integrations/payments/orders`
  - `app/api/integrations/payments/health/route.ts`
  - `app/api/integrations/payments/sessions/route.ts`
  - `docs/implemented-payments.md`
- Expected payoff: one-repo payments ownership, simpler operations, faster debugging

### PAY-3 Split payments (MercadoPago Partners) per club
- Priority: P1
- Status: blocked
- Depends on: OPS-1, PAY-2
- Scope:
  - define a fixed Ballbox split-payments setup equivalent to v1 so every approved payment can route a configurable percentage to the club owning the vending machine
  - add an admin CTA in club configuration to start/link MercadoPago partner onboarding for that club
  - persist the minimum per-club MercadoPago partner metadata/credentials required for payout routing and operational support
  - document security and rotation rules for stored credentials before go-live
- Notes:
  - implementation details are intentionally pending discovery when this task is started
  - keep this behind real-world payments validation first (`OPS-1`)
- Expected payoff: platform-controlled split payments with club-aligned revenue distribution

## P2 Sequenced Refactors

### ARCH-4 Domain folder architecture
- Priority: P2
- Status: blocked
- Depends on: ARCH-2, ARCH-3
- Scope:
  - move flat prefix-based modules into domain folders in `lib/` and `components/`
  - preserve behavior; focus on discoverability and ownership boundaries
- Expected payoff: major readability improvement for future work

### ARCH-5 Route IA normalization (`/classes*` + `/coaches*`)
- Priority: P2
- Status: blocked
- Depends on: DOC-1
- Scope:
  - align route grouping and naming with real auth/surface behavior
  - keep redirects/aliases explicit during transition
- Expected payoff: lower confusion in feature placement and routing maintenance

### ARCH-6 Naming conventions and metadata cleanup
- Priority: P2
- Status: blocked
- Depends on: ARCH-4
- Scope:
  - codify canonical naming terms and singular/plural rules
  - align package metadata with repo identity
- Expected payoff: fewer naming mismatches and clearer ownership vocabulary


app/notification/mercadopago/route.ts:7:} from "@/lib/payments-mercadopago";
app/notification/mercadopago/route.ts:23:    console.info("[payments][mercadopago-webhook] ignored", {
app/notification/mercadopago/route.ts:43:    console.warn("[payments][mercadopago-webhook] order fetch failed", {
app/notification/mercadopago/route.ts:63:    console.info("[payments][mercadopago-webhook] ignored", {
app/notification/mercadopago/route.ts:82:  console.info("[payments][mercadopago-webhook] approved persisted", {
app/notification/mercadopago/route.ts:93:    source: "mercadopago-webhook",
app/layout.tsx:4:import { RouteFeedback } from '@/components/route-feedback'
app/api/admin/summary/route.ts:3:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/admin/summary/route.ts:41:        mercadopagoWebhookPath: "/notification/mercadopago",
app/api/admin/summary/route.ts:42:        mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
app/api/admin/summary/route.ts:43:        mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
app/api/admin/summary/route.ts:44:        mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
app/api/coaches/availability/route.ts:16:  const [hour, minute] = value.split(":").map((part) => Number(part));
app/api/integrations/payments/approved/route.ts:10:      message: "Use the native MercadoPago flow: create orders at /api/integrations/payments/orders and receive webhooks at /notification/mercadopago.",
app/api/integrations/payments/approved/route.ts:21:      message: "Use the native MercadoPago webhook at /notification/mercadopago.",
app/api/integrations/payments/orders/route.ts:12:import { createMercadoPagoQrOrder, getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/integrations/payments/orders/route.ts:93:  const notificationUrl = publicAppUrl ? `${publicAppUrl}/notification/mercadopago?source_news=webhooks` : undefined;
app/api/integrations/payments/health/route.ts:1:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/integrations/payments/health/route.ts:16:    mercadopagoWebhookPath: "/notification/mercadopago",
app/api/integrations/payments/health/route.ts:17:    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
app/api/integrations/payments/health/route.ts:18:    mercadopago: {
app/api/integrations/payments/health/route.ts:28:      "mercadopago webhook intake",
app/api/integrations/payments/health/route.ts:29:      "mercadopago qr order creation",
app/api/integrations/payments/health/route.ts:30:      "mercadopago order fetch",
app/api/integrations/payments/sessions/route.ts:2:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/integrations/payments/sessions/route.ts:18:    mercadopagoWebhookPath: "/notification/mercadopago",
app/api/integrations/payments/sessions/route.ts:19:    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
app/api/integrations/payments/sessions/route.ts:20:    mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
app/api/integrations/payments/sessions/route.ts:21:    mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
app/api/integrations/payments/sessions/route.ts:22:    mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
lib/coach-availability-matching.ts:54:  const [hour, minute] = value.split(":").map((part) => Number(part));
lib/coaches-backend.ts:272:  const [hour, minute] = value.split(":").map((part) => Number(part));
lib/payments-mercadopago.ts:58:    publicApiBaseUrl: normalizeBaseUrl(process.env.BALLBOX_MERCADOPAGO_API_BASE_URL ?? "https://api.mercadopago.com"),
lib/payments-mercadopago.ts:71:      .split(",")
lib/payments-mercadopago.ts:75:        const [key, ...rest] = part.split("=");
lib/account-credentials.ts:29:  const [version, salt, expectedHash] = passwordHash.split(":");
lib/account-credentials.ts:45:  const [encodedEmail, signature] = value.split(SESSION_SEPARATOR);
lib/club-accounts-backend.ts:356:  const [hour, minute] = value.split(":").map((part) => Number(part));
lib/coach-students-backend.ts:92:  const [hour, minute] = value.split(":").map((part) => Number(part));
lib/ballbox-datetime.ts:70:  const [year, month, day] = value.split("-").map((part) => Number(part));
docs/implemented-network.md:10:- Sports-side coach flows now also read those shared `Venue` scheduling fields to clamp coach availability and preview the extra club fee added on top of the coach's own package price, applying the venue discount only when that coach belongs to the club.
docs/maintainability-priorities-2026-03.md:16:  - P0.2 current `ballbox-store.ts` split phase
docs/maintainability-priorities-2026-03.md:17:  - P0.3 current admin-client split phase for `network`, `players`, and `sports`
docs/maintainability-priorities-2026-03.md:24:  - P1.7 route feedback redesign
docs/maintainability-priorities-2026-03.md:94:  - Extract repeated admin UI primitives only after the data/mutation split is clear.
docs/maintainability-priorities-2026-03.md:162:### 7. Replace global imperative route feedback with narrower pending UX
docs/maintainability-priorities-2026-03.md:164:- Problem: `components/route-feedback.tsx` mutates document-level interactions, styles, and disabled states.
docs/maintainability-priorities-2026-03.md:166:  - `components/route-feedback.tsx`
docs/maintainability-priorities-2026-03.md:266:- Problem: some model boundaries feel early-stage and vulnerable to accidental overlap.
docs/coach-availability-rfc.md:29:- We still want the calendar UX to feel real, even when availability is partial or missing.
docs/coach-availability-rfc.md:201:- If the coach belongs to a club and already has a sede, Ballbox shows the venue fee and cancellation window that get added on top of the coach's own package price.
docs/coach-availability-rfc.md:202:- If that venue has a club-coach discount, Ballbox shows the discounted fee for club coaches while leaving the external-coach fee unchanged.
docs/coach-availability-rfc.md:210:- Club first sees a dashboard with venue-level and coach-level metrics plus estimated reservation-fee contribution, and can then move into a separate configuration section for invite links, venue creation, and roster management. Navigation between those sections now lives in the shared top navbar.
docs/next-steps-queue.md:72:- Progress (2026-04-14): payments migration to host app started in one feature branch; health/sessions now expose internal MercadoPago mode, Ballbox ships `/notification/mercadopago` intake with signature validation + order fetch, and native order creation now exists at `/api/integrations/payments/orders`.
docs/next-steps-queue.md:103:### ARCH-3 Test taxonomy split
docs/next-steps-queue.md:131:  - `/notification/mercadopago`
docs/next-steps-queue.md:143:  - define a fixed Ballbox split-payments setup equivalent to v1 so every approved payment can route a configurable percentage to the club owning the vending machine
docs/next-steps-queue.md:150:- Expected payoff: platform-controlled split payments with club-aligned revenue distribution
docs/mercadopago-webhook-replay-notes-2026-04-23.md:6:- Replayed real Ballbox Mercado Pago webhook payloads against `https://ballbox.app/notification/mercadopago`.
docs/implemented-host-app.md:22:- Ballbox owns native MercadoPago QR order creation at `/api/integrations/payments/orders` and native webhook intake at `/notification/mercadopago`.
docs/implemented-host-app.md:47:- `/clubs/coaches` now behaves as a club operations surface with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: clubs can review the current week's coach availability/reservations, inspect professor/reservation money splits, manage venue quick settings, and work the coach roster from the same navbar.
docs/implemented-sports.md:13:- `/coaches/settings` now opens on a coach dashboard split into `Calendar` and `Analytics`: calendar shows the current week's availability plus taken student reservations, while analytics shows weekly earnings, student consistency rows, and review placeholders before the separate `Mi Perfil` and `Alumnos` sections.
docs/implemented-sports.md:16:- `/clubs/coaches` now opens on a club workspace with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: calendar shows the current week's coach availability/reservations with sede/coach/court filters plus quick manual class loading, analytics shows professor/reservation money splits, `Sedes` handles venue creation and quick venue reservation settings, and `Profesores` handles invite links plus the club coach table.
docs/implemented-sports.md:34:- `Venue` now also stores one opening time, one closing time, weekly half-hour coach-availability ranges, whether that venue accepts external coaches, a reservation price for coach classes, a club-coach-only discount over that reservation fee, and a late-cancellation window in hours.
docs/implemented-payments.md:5:- `/notification/mercadopago` is the first-party MercadoPago QR webhook route that fetches the upstream order, ignores unsupported merchant-order topics, and persists approved orders.
docs/implemented-payments.md:18:- `/notification/mercadopago`
docs/implemented-payments.md:30:- Ballbox native MercadoPago webhook path is `/notification/mercadopago`.
docs/implemented-payments.md:32:- That route now also attaches a public `notification_url` so Mercado Pago sends QR order updates back to `/notification/mercadopago`.
docs/implemented-payments.md:44:- Recent replay validation against two older live `ORD...` ids returned `MERCADOPAGO_ORDER_NOT_APPROVED_expired`; see `docs/mercadopago-webhook-replay-notes-2026-04-23.md` when resuming webhook persistence checks.
docs/implemented-payments.md:52:  - public HTTPS exposure for Ballbox webhook endpoint `/notification/mercadopago`
docs/coaches-jtbd-workflow-map.md:10:- If evidence conflicts across segments, split into separate workflow maps.
docs/coaches-jtbd-workflow-map.md:51:- Stress / trust cost: high if coaches feel disorganized in front of students or clubs
docs/coaches-jtbd-workflow-map.md:88:  - whether coaches or clubs feel the pain most strongly
tests/unit/payments/mercadopago.test.ts:7:} from "@/lib/payments-mercadopago";
tests/unit/payments/mercadopago.test.ts:9:describe("mercadopago helpers", () => {
tests/unit/payments/mercadopago.test.ts:15:      "https://ballbox.app/notification/mercadopago"
tests/contracts/payments/auth.test.ts:21:        "Use the native MercadoPago flow: create orders at /api/integrations/payments/orders and receive webhooks at /notification/mercadopago.",
tests/contracts/payments/auth.test.ts:30:      message: "Use the native MercadoPago webhook at /notification/mercadopago.",
tests/contracts/payments/auth.test.ts:34:  it("reports native mercadopago health status and config flags", async () => {
tests/contracts/payments/auth.test.ts:48:      mercadopagoWebhookPath: "/notification/mercadopago",
tests/contracts/payments/auth.test.ts:49:      mercadopago: {
tests/contracts/payments/orders-route.test.ts:18:vi.mock("@/lib/payments-mercadopago", () => ({
tests/contracts/payments/orders-route.test.ts:23:    publicApiBaseUrl: "https://api.mercadopago.com",
tests/contracts/payments/orders-route.test.ts:37:  it("creates a mercadopago qr order with canonical reference", async () => {
tests/contracts/payments/orders-route.test.ts:81:        notificationUrl: "https://ballbox.app/notification/mercadopago?source_news=webhooks",
tests/contracts/payments/orders-route.test.ts:130:        notificationUrl: "https://ballbox.app/notification/mercadopago?source_news=webhooks",
tests/e2e/player-runtime-side-effects.spec.ts:6:  const envLines = readFileSync(".env.local", "utf8").split(/\r?\n/);
tests/e2e/support/admin-auth.ts:20:  const envLines = readFileSync(".env.local", "utf8").split(/\r?\n/);
tests/e2e/tvs-runtime-side-effects.spec.ts:6:  const envLines = readFileSync(".env.local", "utf8").split(/\r?\n/);


import crypto from "node:crypto";
import { getPaymentReferenceCorrelation, type PaymentApprovalCallbackInput } from "@/lib/payments";

type MercadoPagoNotificationPayload = {
  id?: string | number;
  data?: {
    id?: string | number;
  };
  resource?: string;
};

type MercadoPagoOrderPayload = {
  id?: string | number;
  external_reference?: string;
  currency?: string;
  total_amount?: number;
  date_created?: string;
  status?: string;
  order_status?: string;
  payments?: Array<{
    id?: string | number;
    status?: string;
    status_detail?: string;
  }>;
  items?: Array<{
    id?: string | number;
  }>;
};

type MercadoPagoConfig = {
  accessToken: string | null;
  webhookSecret: string | null;
  externalPosId: string | null;
  publicApiBaseUrl: string;
};

function resolveStringEnv(...values: Array<string | undefined>) {
  for (const value of values) {
    const next = value?.trim();
    if (next) return next;
  }

  return null;
}

function normalizeBaseUrl(url: string) {
  return url.endsWith("/") ? url.slice(0, -1) : url;
}

export function getMercadoPagoConfig(): MercadoPagoConfig {
  return {
    accessToken: resolveStringEnv(process.env.BALLBOX_MERCADOPAGO_ACCESS_TOKEN, process.env.MERCADOPAGO_ACCESS_TOKEN),
    webhookSecret: resolveStringEnv(
      process.env.BALLBOX_MERCADOPAGO_WEBHOOK_SECRET,
      process.env.MERCADOPAGO_WEBHOOK_SECRET
    ),
    externalPosId: resolveStringEnv(process.env.BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID),
    publicApiBaseUrl: normalizeBaseUrl(process.env.BALLBOX_MERCADOPAGO_API_BASE_URL ?? "https://api.mercadopago.com"),
  };
}

function amountMinorToMercadoPagoAmount(amountMinor: number) {
  return (amountMinor / 100).toFixed(2);
}

function parseMercadoPagoSignatureHeader(signatureHeader: string | null) {
  if (!signatureHeader) return null;

  const tokens = Object.fromEntries(
    signatureHeader
      .split(",")
      .map((part) => part.trim())
      .filter((part) => part.length > 0)
      .map((part) => {
        const [key, ...rest] = part.split("=");
        return [key, rest.join("=")];
      })
  );

  const ts = tokens.ts?.trim().replace(/^"|"$/g, "");
  const v1 = tokens.v1?.trim().replace(/^"|"$/g, "").toLowerCase();
  if (!ts || !v1) return null;

  return { ts, v1 };
}

function extractIdFromResource(resource: string | undefined) {
  if (!resource) return null;
  const match = resource.match(/(\d+)(?:\/?$)/);
  return match?.[1] ?? null;
}

export function extractMercadoPagoOrderId(payload: MercadoPagoNotificationPayload, requestUrl: string) {
  const fromBodyData = payload.data?.id;
  if (fromBodyData !== undefined && fromBodyData !== null && String(fromBodyData).trim().length > 0) {
    return String(fromBodyData).trim();
  }

  const fromBodyId = payload.id;
  if (fromBodyId !== undefined && fromBodyId !== null && String(fromBodyId).trim().length > 0) {
    return String(fromBodyId).trim();
  }

  const fromResource = extractIdFromResource(payload.resource);
  if (fromResource) return fromResource;

  const url = new URL(requestUrl);
  const fromQueryDataId = url.searchParams.get("data.id");
  if (fromQueryDataId?.trim()) return fromQueryDataId.trim();

  const fromQueryId = url.searchParams.get("id");
  if (fromQueryId?.trim()) return fromQueryId.trim();

  return null;
}

export function isValidMercadoPagoWebhookSignature(input: {
  webhookSecret: string | null;
  signatureHeader: string | null;
  requestIdHeader: string | null;
  dataId: string | null;
}) {
  if (!input.webhookSecret) {
    return {
      ok: false as const,
      reason: "MERCADOPAGO_WEBHOOK_SECRET_NOT_CONFIGURED",
    };
  }

  if (!input.dataId) {
    return {
      ok: false as const,
      reason: "MERCADOPAGO_NOTIFICATION_ID_MISSING",
    };
  }

  const parsed = parseMercadoPagoSignatureHeader(input.signatureHeader);
  if (!parsed) {
    return {
      ok: false as const,
      reason: "MERCADOPAGO_SIGNATURE_INVALID_FORMAT",
    };
  }

  const requestId = input.requestIdHeader?.trim() ?? "";
  const candidateManifests = [
    `id:${input.dataId};request-id:${requestId};ts:${parsed.ts};`,
    `id:${input.dataId};ts:${parsed.ts};`,
  ];

  const receivedBuffer = Buffer.from(parsed.v1, "utf8");
  const matches = candidateManifests.some((manifest) => {
    const expectedDigest = crypto.createHmac("sha256", input.webhookSecret as string).update(manifest).digest("hex");
    const expectedBuffer = Buffer.from(expectedDigest, "utf8");
    return expectedBuffer.length === receivedBuffer.length && crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
  });

  if (!matches) {
    return {
      ok: false as const,
      reason: "MERCADOPAGO_SIGNATURE_MISMATCH",
    };
  }

  return {
    ok: true as const,
  };
}

export async function fetchMercadoPagoOrder(orderId: string) {
  const config = getMercadoPagoConfig();

  if (!config.accessToken) {
    return {
      ok: false as const,
      status: 500,
      error: "MERCADOPAGO_ACCESS_TOKEN_NOT_CONFIGURED",
    };
  }

  const response = await fetch(`${config.publicApiBaseUrl}/v1/orders/${encodeURIComponent(orderId)}`, {
    cache: "no-store",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${config.accessToken}`,
    },
  });

  if (!response.ok) {
    return {
      ok: false as const,
      status: response.status,
      error: `MERCADOPAGO_ORDER_FETCH_FAILED_${response.status}`,
    };
  }

  return {
    ok: true as const,
    order: (await response.json()) as MercadoPagoOrderPayload,
  };
}

export function mapMercadoPagoOrderToApprovedPayment(order: MercadoPagoOrderPayload):
  | { ok: true; value: PaymentApprovalCallbackInput }
  | { ok: false; reason: string } {
  const orderStatus = (order.status ?? order.order_status ?? "").toLowerCase();
  if (!["paid", "approved", "processed"].includes(orderStatus)) {
    return {
      ok: false,
      reason: `MERCADOPAGO_ORDER_NOT_APPROVED_${orderStatus || "UNKNOWN"}`,
    };
  }

  const sessionId = order.id !== undefined && order.id !== null ? String(order.id) : "";
  const externalReference = order.external_reference?.trim() ?? "";
  const correlation = getPaymentReferenceCorrelation({
    externalReference,
    vendingMachineId: "",
    machineTxnId: "",
  });

  if (!sessionId || !correlation.parsedReference) {
    return {
      ok: false,
      reason: "MERCADOPAGO_ORDER_CORRELATION_INVALID",
    };
  }

  const productId = order.items?.[0]?.id !== undefined && order.items?.[0]?.id !== null ? String(order.items[0].id) : "";
  if (!productId) {
    return {
      ok: false,
      reason: "MERCADOPAGO_ORDER_PRODUCT_ID_MISSING",
    };
  }

  const amountMinor = Number.isFinite(order.total_amount)
    ? Math.round((order.total_amount as number) * 100)
    : NaN;
  if (!Number.isFinite(amountMinor) || amountMinor < 0) {
    return {
      ok: false,
      reason: "MERCADOPAGO_ORDER_AMOUNT_INVALID",
    };
  }

  const approvedAt = order.date_created && !Number.isNaN(Date.parse(order.date_created))
    ? new Date(order.date_created).toISOString()
    : new Date().toISOString();

  const providerPaymentId =
    order.payments?.[0]?.id !== undefined && order.payments?.[0]?.id !== null
      ? String(order.payments[0].id)
      : sessionId;

  return {
    ok: true,
    value: {
      sessionId,
      vendingMachineId: correlation.parsedReference.vendingMachineId,

[122 more lines in file. Use offset=261 to continue.]


generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum ClubStatus {
  ACTIVE
  PILOT
  INACTIVE
}

enum VenueStatus {
  ACTIVE
  INACTIVE
}

enum ScreenStatus {
  ACTIVE
  INACTIVE
}

enum PaymentSessionStatus {
  APPROVED
}

enum ClassOfferStatus {
  ACTIVE
  INACTIVE
}

enum ClassRequestStatus {
  NEW
  CONTACTED
  CONFIRMED
  REJECTED
  EXPIRED
}

enum ClassRequestSelectionMode {
  SLOT
  FLEXIBLE
}

enum CoachClubSelectionMode {
  ALL
  CUSTOM
}

enum CoachSalesPackageFormat {
  INDIVIDUAL
  GROUP
}

enum CoachStudentBookingStatus {
  SCHEDULED
  CANCELED
}

model Advertiser {
  id          String     @id @default(cuid())
  name        String
  contactInfo String?
  campaigns   Campaign[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @default(now()) @updatedAt
}

model Club {
  id             String       @id @default(cuid())
  name           String?
  slug           String?      @unique
  atcSportclubId String?      @unique
  status         ClubStatus?
  contactEmail   String?      @unique
  passwordHash   String?
  venues         Venue[]
  memberships    Membership[]
  coaches        Coach[]
  coachClubs     CoachClub[]
  coachInvites   CoachInvite[]
  createdAt      DateTime     @default(now())
  updatedAt      DateTime     @default(now()) @updatedAt
}

model Venue {
  id              String           @id @default(cuid())
  club            Club             @relation(fields: [clubId], references: [id])
  clubId          String
  name            String
  slug            String
  status          VenueStatus      @default(ACTIVE)
  openingTime     String           @default("07:00")
  closingTime     String           @default("23:00")
  coachReservationPrice Int        @default(0)
  clubCoachReservationDiscount Int @default(0)
  coachCancellationWindowHours Int @default(24)
  externalCoachesAllowed Boolean   @default(false)
  courts          VenueCourt[]
  coachHours      VenueCoachHour[]
  coaches         Coach[]
  vendingMachines VendingMachine[]
  screens         Screen[]
  openMatches     OpenMatch[]
  classes         Class[]
  classOffers     ClassOffer[]
  coachStudentBookings CoachStudentBooking[]
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @default(now()) @updatedAt

  @@unique([clubId, slug])
}

model VenueCourt {
  id              String          @id @default(cuid())
  venue           Venue           @relation(fields: [venueId], references: [id])
  venueId         String
  name            String
  covered         Boolean         @default(false)
  coachesEnabled  Boolean         @default(true)
  sortOrder       Int             @default(0)
  coachStudentBookings CoachStudentBooking[]
  createdAt       DateTime        @default(now())
  updatedAt       DateTime        @default(now()) @updatedAt

  @@unique([venueId, name])
  @@index([venueId, sortOrder])
}

model VenueCoachHour {
  id        String   @id @default(cuid())
  venue     Venue    @relation(fields: [venueId], references: [id])
  venueId   String
  dayOfWeek Int
  startTime String
  endTime   String
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  @@index([venueId, dayOfWeek])
}

model VendingMachine {
  id              String           @id
  venue           Venue            @relation(fields: [venueId], references: [id])
  venueId         String
  label           String
  active          Boolean          @default(true)
  screen          Screen?
  paymentSessions PaymentSession[]
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @default(now()) @updatedAt
}

model TvDevice {
  id            String       @id @default(cuid())
  name          String
  location      String?
  manifestUrl   String?
  lastSeen      DateTime?
  screen        Screen?      @relation(fields: [screenId], references: [id])
  screenId      String?      @unique
  impressions   Impression[]
  createdAt     DateTime     @default(now())
  updatedAt     DateTime     @default(now()) @updatedAt
}

model Screen {
  id               String          @id @default(cuid())
  venue            Venue           @relation(fields: [venueId], references: [id])
  venueId          String
  vendingMachine   VendingMachine? @relation(fields: [vendingMachineId], references: [id])
  vendingMachineId String?         @unique
  tvDevice         TvDevice?
  name             String
  location         String?
  status           ScreenStatus    @default(ACTIVE)
  assignments      Assignment[]
  createdAt        DateTime        @default(now())
  updatedAt        DateTime        @default(now()) @updatedAt
}

model Campaign {
  id          String       @id @default(cuid())
  advertiser  Advertiser   @relation(fields: [advertiserId], references: [id])
  advertiserId String
  name        String
  startAt     DateTime
  endAt       DateTime
  creatives   Creative[]
  assignments Assignment[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @default(now()) @updatedAt
}

model Creative {
  id          String       @id @default(cuid())
  campaign    Campaign     @relation(fields: [campaignId], references: [id])
  campaignId  String
  url         String
  duration    Int
  impressions Impression[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @default(now()) @updatedAt
}

model Assignment {
  id         String    @id @default(cuid())
  screen     Screen    @relation(fields: [screenId], references: [id])
  screenId   String
  campaign   Campaign  @relation(fields: [campaignId], references: [id])
  campaignId String
  active     Boolean   @default(true)
  startsAt   DateTime?
  endsAt     DateTime?
  createdAt  DateTime  @default(now())
  updatedAt  DateTime  @default(now()) @updatedAt

  @@unique([screenId, campaignId])
}

model Impression {
  id         String   @id @default(cuid())
  tvDevice   TvDevice @relation(fields: [tvDeviceId], references: [id])
  tvDeviceId String
  creative   Creative @relation(fields: [creativeId], references: [id])
  creativeId String
  shownAt    DateTime @default(now())
}

model Product {
  id              String           @id
  name            String
  paymentSessions PaymentSession[]
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @default(now()) @updatedAt
}

model PaymentSession {
  id                String                  @id
  vendingMachine    VendingMachine          @relation(fields: [vendingMachineId], references: [id])
  vendingMachineId  String
  product           Product                 @relation(fields: [productId], references: [id])
  productId         String
  machineTxnId      String
  externalReference String                  @unique
  amountMinor       Int
  currency          String
  status            PaymentSessionStatus @default(APPROVED)
  approvedAt        DateTime
  lateApproval      Boolean                 @default(false)
  providerPaymentId String?
  source            String                  @default("ballbox-payment-integration")
  rawPayload        Json
  createdAt         DateTime                @default(now())
  updatedAt         DateTime                @default(now()) @updatedAt

  @@index([approvedAt])
  @@index([vendingMachineId, approvedAt])
}

model Coach {
  id          String       @id @default(cuid())
  name        String
  email       String?      @unique
  passwordHash String?
  mercadoPagoHandle String?
  club        Club?        @relation(fields: [clubId], references: [id])
  clubId      String?
  venue       Venue?       @relation(fields: [venueId], references: [id])
  venueId     String?
  clubSelectionMode CoachClubSelectionMode @default(ALL)
  clubSelections CoachClub[]
  availabilities CoachAvailability[]
  salesPackages CoachSalesPackage[]
  classOffers ClassOffer[]
  studentBookings CoachStudentBooking[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @default(now()) @updatedAt
}

model CoachAvailability {
  id        String   @id @default(cuid())
  coach     Coach    @relation(fields: [coachId], references: [id])
  coachId   String
  dayOfWeek Int
  startTime String
  endTime   String
  timezone  String
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  @@index([coachId, dayOfWeek])
}

model CoachClub {
  coach   Coach @relation(fields: [coachId], references: [id])
  coachId String
  club    Club  @relation(fields: [clubId], references: [id])
  clubId  String

  @@id([coachId, clubId])
  @@index([clubId])
}

model CoachInvite {
  id         String   @id @default(cuid())
  club       Club     @relation(fields: [clubId], references: [id])
  clubId     String
  email      String?
  token      String   @unique
  acceptedAt DateTime?
  createdAt  DateTime @default(now())
  updatedAt  DateTime @default(now()) @updatedAt

  @@index([clubId, createdAt])
  @@index([email])

[132 more lines in file. Use offset=321 to continue.]


import crypto from "node:crypto";
import {
  paymentOrderCreateResponseSchema,
  paymentOrderCreateSchema,
} from "@/lib/contracts/payments";
import {
  parsePaymentsJsonBody,
  paymentsErrorResponse,
  paymentsInvalidPayloadResponse,
  paymentsJsonResponse,
} from "@/lib/http/payments-route-helpers";
import { createMercadoPagoQrOrder, getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { buildPaymentExternalReference, getPaymentProductDefaultPriceMinor } from "@/lib/payments";
import { getPublicAppUrl } from "@/lib/public-url";
import { prisma } from "@/lib/prisma";

function buildMachineTxnId() {
  return `txn_${Date.now()}_${crypto.randomUUID().slice(0, 8)}`;
}

export async function GET() {
  const config = getMercadoPagoConfig();

  return paymentsJsonResponse({
    ok: true,
    configured: {
      accessToken: Boolean(config.accessToken),
      webhookSecret: Boolean(config.webhookSecret),
      externalPosId: Boolean(config.externalPosId),
    },
    defaults: {
      mode: "dynamic",
      expirationTime: "PT15M",
      externalPosId: config.externalPosId,
    },
  });
}

export async function POST(request: Request) {
  const parsedBody = await parsePaymentsJsonBody(request, paymentOrderCreateSchema);
  if (!parsedBody.success) {
    return paymentsInvalidPayloadResponse(parsedBody.error);
  }

  const vendingMachine = await prisma.vendingMachine.findUnique({
    where: { id: parsedBody.data.vendingMachineId },
    select: { id: true, label: true },
  });
  if (!vendingMachine) {
    return paymentsErrorResponse("VENDING_MACHINE_NOT_FOUND", 404);
  }

  const product = await prisma.product.findUnique({
    where: { id: parsedBody.data.productId },
    select: { id: true, name: true },
  });
  if (!product) {
    return paymentsErrorResponse("PRODUCT_NOT_FOUND", 404);
  }

  const config = getMercadoPagoConfig();
  const externalPosId = parsedBody.data.externalPosId ?? config.externalPosId;
  if (!externalPosId) {
    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
      field: "externalPosId",
      message: "Missing external POS id in payload and BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID",
    });
  }

  const machineTxnId = buildMachineTxnId();
  const externalReference = buildPaymentExternalReference({
    vendingMachineId: parsedBody.data.vendingMachineId,
    machineTxnId,
  });
  const amountMinor = parsedBody.data.amountMinor ?? getPaymentProductDefaultPriceMinor(product.id) ?? null;
  if (amountMinor === null) {
    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
      field: "amountMinor",
      message: "Missing amountMinor and product priceMinor",
    });
  }

  if (amountMinor < 1500) {
    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
      field: "amountMinor",
      message: "Amount must be at least 15.00 ARS",
    });
  }

  const description =
    parsedBody.data.description?.trim() || `${product.name} (${vendingMachine.label || vendingMachine.id})`;
  const publicAppUrl = getPublicAppUrl();
  const notificationUrl = publicAppUrl ? `${publicAppUrl}/notification/mercadopago?source_news=webhooks` : undefined;

  const createResult = await createMercadoPagoQrOrder({
    vendingMachineId: parsedBody.data.vendingMachineId,
    productId: parsedBody.data.productId,
    amountMinor,
    currency: parsedBody.data.currency,
    externalReference,
    machineTxnId,
    externalPosId,
    mode: parsedBody.data.mode,
    description,
    expirationTime: parsedBody.data.expirationTime,
    notificationUrl,
  });

  if (!createResult.ok) {
    return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 502, {
      error: createResult.error,
      status: createResult.status,
      details: createResult.details,
    });
  }

  return paymentsJsonResponse(paymentOrderCreateResponseSchema.parse({ ok: true, item: createResult.value }), 201);
}


# Mercado Pago webhook replay notes

Date: 2026-04-23

## What was tried
- Replayed real Ballbox Mercado Pago webhook payloads against `https://ballbox.app/notification/mercadopago`.
- Tested these order ids from prior prod logs:
  - `ORD01KPV95KCFED46SQ2J23C8JR75`
  - `ORD01KPV68YK3W6HPH29ZK6ZY58G8`

## Result
- Both replays returned `202 Accepted`.
- Ballbox ignored both because Mercado Pago order fetch returned the orders as expired:
  - `MERCADOPAGO_ORDER_NOT_APPROVED_expired`

## Meaning
- The webhook endpoint is live.
- Order fetch works.
- Persist-only-on-approved logic is working.
- These specific replay ids are no longer useful for persistence validation because the orders expired in Mercado Pago.

## Current blocker
- Need either:
  - a fresh approved `order.processed` id still valid in Mercado Pago, or
  - a real buyer-side payment to create a new approved order.

## Recommended next step
- Keep the webhook path as-is.
- When resuming, use a fresh approved order id from a recent live payment or the buyer app flow.


**Evaluating data structure needs**

I’m thinking about whether I need to add more lines in Prisma to support club metadata. I should look into the split/payment relationship, although it seems there might not be any. I really need to inspect the payments library further. Additionally, I wonder if I should consider current external references and the order payload support as well. Okay, this feels like a process, but I’m on it!


      productId,
      machineTxnId: correlation.parsedReference.machineTxnId,
      externalReference,
      amountMinor,
      currency: order.currency?.trim() || "ARS",
      status: "approved",
      approvedAt,
      lateApproval: false,
      providerPaymentId,
    },
  };
}

export async function createMercadoPagoQrOrder(input: {
  vendingMachineId: string;
  productId: string;
  amountMinor: number;
  currency: string;
  externalReference: string;
  machineTxnId: string;
  externalPosId: string;
  mode: "static" | "dynamic" | "hybrid";
  description: string;
  expirationTime: string;
  notificationUrl?: string;
}) {
  const config = getMercadoPagoConfig();

  if (!config.accessToken) {
    return {
      ok: false as const,
      status: 500,
      error: "MERCADOPAGO_ACCESS_TOKEN_NOT_CONFIGURED",
    };
  }

  const response = await fetch(`${config.publicApiBaseUrl}/v1/orders`, {
    method: "POST",
    cache: "no-store",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.accessToken}`,
      "X-Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({
      type: "qr",
      total_amount: amountMinorToMercadoPagoAmount(input.amountMinor),
      description: input.description,
      external_reference: input.externalReference,
      expiration_time: input.expirationTime,
      config: {
        qr: {
          external_pos_id: input.externalPosId,
          mode: input.mode,
        },
      },
      transactions: {
        payments: [
          {
            amount: amountMinorToMercadoPagoAmount(input.amountMinor),
          },
        ],
      },
      items: [
        {
          title: input.description,
          unit_price: amountMinorToMercadoPagoAmount(input.amountMinor),
          quantity: 1,
          unit_measure: "unit",
          external_code: input.productId,
          external_categories: [{ id: "ballbox-product" }],
        },
      ],
      ...(input.notificationUrl
        ? {
            notification_url: input.notificationUrl,
          }
        : {}),
    }),
  });

  const payload = (await response.json().catch(() => null)) as
    | {
        id?: string | number;
        external_reference?: string;
        status?: string;
        status_detail?: string;
        expiration_time?: string;
        type_response?: { qr_data?: string };
      }
    | null;

  if (!response.ok) {
    return {
      ok: false as const,
      status: response.status,
      error: `MERCADOPAGO_ORDER_CREATE_FAILED_${response.status}`,
      details: payload,
    };
  }

  return {
    ok: true as const,
    value: {
      orderId: payload?.id ? String(payload.id) : "",
      externalReference: payload?.external_reference ?? input.externalReference,
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      amountMinor: input.amountMinor,
      currency: input.currency,
      mode: input.mode,
      externalPosId: input.externalPosId,
      status: payload?.status ?? "created",
      statusDetail: payload?.status_detail ?? "created",
      expiresAt: payload?.expiration_time ?? null,
      qrData: payload?.type_response?.qr_data ?? null,
    },
  };
}