Parallel: 3/3 succeeded

[dynamic] completed: Implementable now, local-only:

1. Owner/config resolution layer
- Add `PaymentOwnerMode` + resolved...

[dynamic] completed: Ranked useful work left, skipping `OPS-2` and machine-dependent tracks:

1. `ARCH-5` Route IA normal...

[dynamic] completed: Can advance now:

## Write new artifacts

1. `docs/tcn-payments-adapter-contract.md`
- Freeze Ballbo...

[User] Task: Inspect Ballbox payment docs/code and determine which PAY-3 slices can be implemented now without physical machines or further user input. Output: concrete slices, blockers, recommended first slice.

[Assistant]
**Inspecting documentation**

I need to inspect the local repository, focusing specifically on the payment documents and potentially the index queue plan. I'll look into the relevant sections to gather my insights. It might also be a good idea to search for "PAY-3" to find more specific information related to this topic. Let's see what I uncover as I dig into the documentation!

{"id":"rs_0078a1731493d374016a0477e501008190b29d6f54060d2570","type":"reasoning","encrypted_content":"gAAAAABqBHfp9llexpQTA6O54JQ5LvfQlq_Jr0vc1Ra0CRYpqRZnBmIS-cG31UENkF_N8s5R_xzLsp7z4UZKygTF4pMRqqw8IVPUINUOMwBmqJRsTHnyrJ3yQwOP9PlgCrfuteG7cpF9kxMes1J7yV5Rdhq-Hb_IZ5hrCIW3nb-tfmH-wf4fSnr4J6ccjSb0qB3KSg2hDzlsXEStKtkXEqJ-j7pW0ONjGW88om6rbri-UYRA6m74JRdEH5ZTpcqqzTulkELdDPVwO3d7MCM3b7nfkfdPAjSXOTvWmFiGI4DDyxfDV3nOLOn1N993W5CEo6COV9Ynpk93IF6pduEETQSZ6OTzkSGoS152kQaPXPHmyAzoeyWYjwp3y08sMzBDA8Wot1O2AxkySoxCxVhE4_Kz-0TkPy0ORzBeQ5A1CA8BWGCzawBgEzYrwcBcNWNbBbTeN65HLnz2OlgymHl_l47aS9CcNUXpKy6J-PgSLRENmxucGaIp4I7bMpUKcNj00lFefHlvJWfRCgHNviYDXMJ8bs7gCbL8ZQH_SAmoUw3ttkN_csOCda8EvOC-wDc_bEyqO08AV275s-LVivXfgH1ZFa-ynlt0isoYlAR7WDrfa3jJ4TDWt1ecalpPhdVVmJUPdDMJkbl5hmbZISJ3wmGt4eX6AcGG8FflzhaI4vSt7agOv3Rl469uDftKsRWqI4QvZr52xc1q9FMRZcbGIK0KGJWiILzL7rgYE1kPK0TSVLu94ggtKaeunkvEhe2JOikPIeUP7c94QQJHR889IF5YIeoae7vROQpPFpim97kRfEWmIUwgHyupFoOQ7TJZSjll9r4l7Pqhz7qbN5VykihfLeRXCS9TFpigKeCUXE8rSSJYElze-3KtJIfjuIhqsXnSfpSupGCi8VQIT1JeYYeech09mWZ14w4vpsqSw7tBiqRpcDmidGpzdvyX_7b-OPJQXJ_pi75UeJsucChBHY6OOPNY1OrZgC7Q6lcf50OAbNIWcT0h3im0OkulW9uaNybCiz7xuEmYZ62Ofmjlzrkg1XLBYZWol8XHxdH-yB0aQ3zhtNA88gA=","summary":[{"type":"summary_text","text":"**Inspecting documentation**\n\nI need to inspect the local repository, focusing specifically on the payment documents and potentially the index queue plan. I'll look into the relevant sections to gather my insights. It might also be a good idea to search for \"PAY-3\" to find more specific information related to this topic. Let's see what I uncover as I dig into the documentation!"}]}

bash

pwd && git status --short && rg -n "PAY-3|payment|payments|Stripe|terminal|reader" -S docs PLAN.md . --glob '!node_modules' --glob '!.next'

[toolResult]
./docs/implemented-payments.md:14:- `/api/admin/payments/sessions`
./docs/implemented-payments.md:15:- `/api/admin/payments/sessions/[sessionId]`
./docs/implemented-payments.md:16:- `/api/integrations/payments/health`
./docs/implemented-payments.md:17:- `/api/integrations/payments/sessions`
./docs/implemented-payments.md:18:- `/api/integrations/payments/orders`
./docs/implemented-payments.md:22:- Ballbox persists approved payment records as `PaymentSession` rows in Ballbox Postgres.
./docs/implemented-payments.md:23:- Native MercadoPago webhook intake fetches the upstream order, maps approved orders into the canonical Ballbox payment shape, and persists them locally.
./docs/implemented-payments.md:28:- Playwright now covers local `/admin/payments` create, edit, and delete flows with deterministic cleanup of E2E `PaymentSession` rows.
./docs/implemented-payments.md:32:- Ballbox native QR order creation path is `/api/integrations/payments/orders`.
./docs/implemented-payments.md:37:- Legacy approved-callback path `/api/integrations/payments/approved` is removed as an operational integration and now returns `410 PAYMENTS_APPROVED_CALLBACK_REMOVED`.
./docs/implemented-payments.md:38:- Native payment correlation requires canonical `externalReference` values shaped as `payment-vm_{vendingMachineId}-txn_{machineTxnId}`.
./docs/implemented-payments.md:43:- Real-world validation is now closed: one real public webhook and one real approved payment were persisted and verified in `/admin/payments` on 2026-05-13.
./docs/implemented-payments.md:46:- First rollout planning now assumes one shared technical flow with two payment-owner modes: `BALLBOX` for club machines Ballbox collects on, and `TENANT` for client-owned collection setups such as Adidas rental machines; see `docs/payments-machine-rollout-rfc.md`.
./docs/implemented-payments.md:49:- TCN protocol review indicates the `additional hardware devices` warning applies to machine-side QR scanning, while the payment flow (`FunCode=8000`) returns `CodeUrl` for machine-screen QR display; see `docs/tcn-payments-integration-plan.md` and `test-results/tcn-payment-protocol/findings-2026-04-23.md`.
./docs/implemented-payments.md:53:- End-to-end payment validation succeeded against Ballbox native MercadoPago webhook configuration.
./docs/implemented-payments.md:60:  - a real payment was approved upstream
./docs/implemented-payments.md:62:  - Ballbox fetched order details and recorded the approved payment locally
./docs/implemented-payments.md:64:  - the session was visible in `/admin/payments`
./docs/implemented-network.md:28:- `Screen` and `TvDevice` also live in the same shared core and are consumed today by TVs and payments flows.
./docs/implemented-network.md:33:- Cross-links from TVs/payments back into `/admin/network` are not in place yet.
./docs/domain-model-target.md:4:- Define the final Ballbox ownership model across `network`, `payments`, `tvs`, `sports`, and `players`.
./docs/implemented-host-app.md:13:  - `/admin/payments`
./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:23:- Native payment persistence validates canonical `payment-vm_{vendingMachineId}-txn_{machineTxnId}` references before writing local approved sessions.
./docs/implemented-host-app.md:24:- Ballbox exposes admin payments operations under `/api/admin/payments/*`.
./docs/implemented-host-app.md:26:- Route JSON/envelope helper boundaries now live in shared `lib/http/*-route-helpers.ts` modules (network, payments, players, sports) instead of route-local helper files under `app/api`.
./docs/implemented-host-app.md:50:- Browser e2e currently covers TVs offline runtime, active admin navigation, mobile smoke for `/admin/network` and `/admin/tvs`, network CRUD, payments CRUD/reconciliation smoke, players CRUD, and sports CRUD.
./docs/coaches-opportunity-brief.md:9:- Ballbox today already has host app, admin, network, TVs, payments, players, and a sports foundation.
./docs/coaches-opportunity-brief.md:56:  - focuses on one recurring operational loop instead of trying to solve scheduling, CRM, payments, and growth all at once
./docs/coach-availability-rfc.md:45:- Booking writes or payment flow.
./components/coach-settings-page-client.tsx:33:  paymentStatus: CoachStudentBooking["paymentStatus"];
./components/coach-settings-page-client.tsx:537:    () => currentWeekBookings.filter((booking) => booking.paymentStatus === "paid").length,
./components/coach-settings-page-client.tsx:541:    () => currentWeekBookings.filter((booking) => booking.paymentStatus === "pending").length,
./components/coach-settings-page-client.tsx:602:        paymentStatus: booking.paymentStatus,
./components/coach-settings-page-client.tsx:1262:                              const paidCount = session.participants.filter((participant) => participant.paymentStatus === "paid").length;
./components/coach-settings-page-client.tsx:1366:                            selectedCalendarSession.participants.filter((participant) => participant.paymentStatus === "paid").length
./components/coach-settings-page-client.tsx:1372:                            selectedCalendarSession.participants.filter((participant) => participant.paymentStatus === "pending").length
./components/coach-settings-page-client.tsx:1402:                                  participant.paymentStatus === "paid"
./components/coach-settings-page-client.tsx:1407:                                {participant.paymentStatus === "paid" ? "Pago" : "Pendiente"}
./docs/payments-machine-implementation-rfc.md:4:- Proposed technical implementation slice for `PAY-3`.
./docs/payments-machine-implementation-rfc.md:5:- Builds on `docs/payments-machine-rollout-rfc.md`.
./docs/payments-machine-implementation-rfc.md:8:Implement one machine-payment flow in Ballbox that:
./docs/payments-machine-implementation-rfc.md:11:- persists approved payments through the Ballbox webhook
./docs/payments-machine-implementation-rfc.md:12:- supports two payment-owner modes without forking the stack:
./docs/payments-machine-implementation-rfc.md:24:- order create route: `app/api/integrations/payments/orders/route.ts`
./docs/payments-machine-implementation-rfc.md:26:- provider helper: `lib/payments-mercadopago.ts`
./docs/payments-machine-implementation-rfc.md:27:- payment persistence + correlation: `lib/payments.ts`
./docs/payments-machine-implementation-rfc.md:28:- payment contracts: `lib/contracts/payments.ts`
./docs/payments-machine-implementation-rfc.md:39:- which payment-owner mode applies to this machine/request/order
./docs/payments-machine-implementation-rfc.md:43:- `lib/payments-mercadopago-config.ts`
./docs/payments-machine-implementation-rfc.md:64:## 2. Resolve payment owner per machine
./docs/payments-machine-implementation-rfc.md:68:- add machine-level payment ownership fields to the Ballbox data model
./docs/payments-machine-implementation-rfc.md:72:- `paymentOwnerMode`
./docs/payments-machine-implementation-rfc.md:73:- `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:74:- optional override `paymentExternalPosId`
./docs/payments-machine-implementation-rfc.md:77:- `paymentOwnerMode`: `BALLBOX` or `TENANT`
./docs/payments-machine-implementation-rfc.md:78:- `paymentOwnerKey`: resolver key for the MP config to use
./docs/payments-machine-implementation-rfc.md:79:- `paymentExternalPosId`: optional machine-specific override when not using the owner default POS
./docs/payments-machine-implementation-rfc.md:89:- `payment-vm_{vendingMachineId}-txn_{machineTxnId}`
./docs/payments-machine-implementation-rfc.md:94:Recommended minimum persisted fields on payment/session side:
./docs/payments-machine-implementation-rfc.md:98:- `paymentOwnerMode`
./docs/payments-machine-implementation-rfc.md:99:- `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:101:If `PaymentSession` should remain approved-only and lean, add a lightweight pre-approval payment operation record instead.
./docs/payments-machine-implementation-rfc.md:103:## 4. Introduce a pre-approval payment operation record
./docs/payments-machine-implementation-rfc.md:109:- add a lightweight table for initiated machine payments
./docs/payments-machine-implementation-rfc.md:121:- `paymentOwnerMode`
./docs/payments-machine-implementation-rfc.md:122:- `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:149:- `lib/payments-machine-flow.ts`
./docs/payments-machine-implementation-rfc.md:154:- create or reuse payment operation row
./docs/payments-machine-implementation-rfc.md:173:## 7. TCN route should sit on top of the machine-payment service
./docs/payments-machine-implementation-rfc.md:176:- TCN route should translate between TCN contract and Ballbox machine-payment service only
./docs/payments-machine-implementation-rfc.md:179:- `app/api/integrations/tcn/payments/init/route.ts` or similar
./docs/payments-machine-implementation-rfc.md:184:- call machine-payment service
./docs/payments-machine-implementation-rfc.md:189:That avoids a separate payment stack for admin testing vs machine runtime.
./docs/payments-machine-implementation-rfc.md:192:- `/api/integrations/payments/orders` becomes a Ballbox test/operator entrypoint
./docs/payments-machine-implementation-rfc.md:194:- both converge on one machine-payment service underneath
./docs/payments-machine-implementation-rfc.md:199:Add machine ownership to `VendingMachine` and payment initiation state to a new `PaymentOperation` table.
./docs/payments-machine-implementation-rfc.md:218:3. from that row Ballbox gets `paymentOwnerMode` + `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:248:Add machine-payment service layer.
./docs/payments-machine-implementation-rfc.md:254:Move `/api/integrations/payments/orders` onto that service.
./docs/payments-machine-implementation-rfc.md:282:- exact TCN payment-success / dispense handshake
./docs/payments-machine-implementation-rfc.md:286:Implement `PAY-3` around:
./docs/payments-machine-implementation-rfc.md:289:- a shared machine-payment service
./docs/payments-machine-implementation-rfc.md:295:- `docs/payments-machine-rollout-rfc.md`
./docs/payments-machine-implementation-rfc.md:296:- `docs/tcn-payments-integration-plan.md`
./docs/payments-machine-implementation-rfc.md:297:- `docs/tcn-payments-execution-plan.md`
./docs/payments-machine-implementation-rfc.md:298:- `docs/implemented-payments.md`
./docs/maintainability-priorities-2026-03.md:15:  - P0.1 fail-closed payments callback auth
./docs/maintainability-priorities-2026-03.md:19:  - P1.6 first route-helper/envelope standardization pass across `network`, `players`, `sports`, and `payments`
./docs/maintainability-priorities-2026-03.md:22:  - P0.3 remaining admin clients: `payments`, `tvs`
./docs/maintainability-priorities-2026-03.md:33:### 1. Fail closed on payments callback auth
./docs/maintainability-priorities-2026-03.md:36:- Risk: `app/api/integrations/payments/approved/route.ts` can accept writes in a misconfigured environment.
./docs/maintainability-priorities-2026-03.md:39:  - `app/api/integrations/payments/approved/route.ts`
./docs/maintainability-priorities-2026-03.md:42:  - It weakens the whole payments integration if env setup drifts.
./docs/maintainability-priorities-2026-03.md:60:  - Split by domain: `network`, `tvs`, `payments`, `players`, `sports`, `classes`.
./docs/maintainability-priorities-2026-03.md:80:  - `components/payments-admin-client.tsx`
./docs/maintainability-priorities-2026-03.md:100:  - apply the same pattern to `payments-admin-client.tsx`
./docs/maintainability-priorities-2026-03.md:129:  - `components/payments-admin-client.tsx`
./docs/maintainability-priorities-2026-03.md:149:  - Inconsistent candidates: `app/api/admin/tvs/screens/[screenId]/route.ts`, `app/api/admin/tvs/tv-devices/route.ts`, `app/api/admin/payments/sessions/route.ts`
./docs/maintainability-priorities-2026-03.md:158:- Follow-on slices shipped: players, sports, and payments route families now use the same helper pattern for JSON parsing plus strict success/error envelopes, including focused invalid-payload contract coverage.
./docs/maintainability-priorities-2026-03.md:241:  - `app/api/integrations/payments/health/route.ts`
./docs/maintainability-priorities-2026-03.md:265:### 14. Tighten domain lifecycle modeling in sports and payments
./docs/maintainability-priorities-2026-03.md:269:  - `lib/payments.ts`
./docs/maintainability-priorities-2026-03.md:284:1. Fix payments callback auth and add env validation.
./docs/coaches-wedges-matrix.md:15:| Collections/admin follow-up | Chasing payments and reminders consumes time | Time savings, maybe cashflow | Low | Medium | Medium | Could be too generic and weakly differentiated |
./docs/coaches-wedges-matrix.md:35:- If a wedge requires broad CRM/payments/attendance/product surface from day one, narrow it or drop it.
./docs/next-steps-queue.md:49:  - deep relative helper imports removed from network, players, sports, and payments route handlers
./docs/next-steps-queue.md:70:  - real payment persisted as approved in `/admin/payments`
./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 order fetch verification, and native order creation now exists at `/api/integrations/payments/orders`.
./docs/next-steps-queue.md:74:- Progress (2026-04-23): TCN protocol review shows the `additional hardware devices` note is about machine-side QR scanning, while the machine payment flow (`FunCode=8000`) already returns `CodeUrl` for machine-screen QR display. This means the admin QR page should be treated as a provider test harness, and production integration should move to a TCN adapter layer.
./docs/next-steps-queue.md:75:- Progress (2026-05-05): first production order-create attempt against `https://ballbox.app/api/integrations/payments/orders` failed before buyer payment with Mercado Pago `400 unsupported_properties` because `notification_url` was rejected (`additionalProperties '$.notification_url' not allowed`). Ballbox should stop sending inline `notification_url` for this account/API surface and rely on the existing account-level Mercado Pago webhook configuration before rerunning the real-payment check.
./docs/next-steps-queue.md:76:- Closure (2026-05-13): a real approved payment now persists locally and is visible in `/admin/payments`. Mercado Pago webhook noise from `topic_merchant_order_wh` remains intentionally ignored. Closure required two Ballbox compatibility fixes for live provider payloads: product id fallback from `items[0].external_code` and robust parsing of `total_amount` when Mercado Pago returns a string.
./docs/next-steps-queue.md:144:### PAY-2 Full payments migration into Ballbox host app
./docs/next-steps-queue.md:154:  - `/api/integrations/payments/orders`
./docs/next-steps-queue.md:155:  - `app/api/integrations/payments/health/route.ts`
./docs/next-steps-queue.md:156:  - `app/api/integrations/payments/sessions/route.ts`
./docs/next-steps-queue.md:157:  - `docs/implemented-payments.md`
./docs/next-steps-queue.md:158:- Expected payoff: one-repo payments ownership, simpler operations, faster debugging
./docs/next-steps-queue.md:160:### PAY-3 TCN vending-machine adapter over native MercadoPago
./docs/next-steps-queue.md:165:  - define the Ballbox machine-facing adapter for TCN payment init (`FunCode=8000`) over the existing MercadoPago order-create route
./docs/next-steps-queue.md:166:  - map TCN `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`, and response `CodeUrl` to Ballbox-native payment fields
./docs/next-steps-queue.md:167:  - define the payment-success handshake back to TCN so approved payments can unlock dispense
./docs/next-steps-queue.md:168:  - support one shared machine payment flow with two payment-owner modes:
./docs/next-steps-queue.md:171:  - keep `/admin/payments` positioned as an operator test/debug harness rather than the production vending UX
./docs/next-steps-queue.md:174:  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
./docs/next-steps-queue.md:175:  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
./docs/next-steps-queue.md:176:  - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
./docs/next-steps-queue.md:186:- Depends on: OPS-1, PAY-2, PAY-3
./docs/next-steps-queue.md:193:  - Ballbox currently runs QR vending payments through the native Mercado Pago order/webhook flow, so split automation is a separate future decision
./docs/next-steps-queue.md:195:  - keep this behind real-world payments validation first (`OPS-1`) and machine-facing TCN adapter definition (`PAY-3`)
./docs/tcn-payments-open-questions.md:1:# TCN payments open questions
./docs/tcn-payments-open-questions.md:12:2. After payment approval, how does the machine learn that the vend can proceed?
./docs/tcn-payments-open-questions.md:18:4. What is the exact payload shape expected for the payment-success notify/result message?
./docs/tcn-payments-open-questions.md:25:7. Are there any timeout / expiry expectations for the QR payment window from the machine side?
./docs/tcn-payments-open-questions.md:26:8. What should Ballbox return to the machine when payment creation fails or times out?
./docs/tcn-payments-open-questions.md:28:10. Are there additional payment-related endpoints beyond `8000` required for a production dispense flow?
./docs/tcn-payments-open-questions.md:37:- A resolved field-mapping note added back into `docs/tcn-payments-integration-plan.md`
./docs/coaches-solution-thesis.md:36:- Not assuming the first win is payments
./docs/payments-machine-rollout-rfc.md:5:- Use this as the master document for payment ownership modes, TCN integration, onboarding, and rollout sequencing.
./docs/payments-machine-rollout-rfc.md:9:- Support two payment-owner modes in that flow:
./docs/payments-machine-rollout-rfc.md:20:- The immediate risk is not payout automation. The immediate risk is getting the machine payment path working end to end.
./docs/payments-machine-rollout-rfc.md:29:- TCN machine payment adapter
./docs/payments-machine-rollout-rfc.md:31:- machine-level payment-owner mode selection
./docs/payments-machine-rollout-rfc.md:54:5. Ballbox fetches order details, verifies approval, persists payment
./docs/payments-machine-rollout-rfc.md:70:5. Ballbox fetches order details with the tenant-specific credentials, verifies approval, persists payment
./docs/payments-machine-rollout-rfc.md:71:6. tenant owns funds; Ballbox is not doing revenue-share settlement for those payments
./docs/payments-machine-rollout-rfc.md:82:- centralized debugging for payment-approved-but-no-vend cases
./docs/payments-machine-rollout-rfc.md:87:- Ballbox owns the payment orchestration path
./docs/payments-machine-rollout-rfc.md:88:- tenant owns the payment account
./docs/payments-machine-rollout-rfc.md:97:- `paymentOwnerMode = BALLBOX | TENANT`
./docs/payments-machine-rollout-rfc.md:98:- payment config reference for Mercado Pago
./docs/payments-machine-rollout-rfc.md:109:But Ballbox must be able to determine which payment config applies when an event arrives.
./docs/payments-machine-rollout-rfc.md:111:- provider order / external reference -> vending machine -> payment owner mode -> MP config
./docs/payments-machine-rollout-rfc.md:116:- Ballbox should implement the TCN payment adapter over the native Mercado Pago provider layer.
./docs/payments-machine-rollout-rfc.md:120:- `docs/tcn-payments-integration-plan.md`
./docs/payments-machine-rollout-rfc.md:121:- `docs/tcn-payments-open-questions.md`
./docs/payments-machine-rollout-rfc.md:122:- `docs/tcn-payments-execution-plan.md`
./docs/payments-machine-rollout-rfc.md:131:- one real approved payment persisted locally
./docs/payments-machine-rollout-rfc.md:145:- `PAY-3`
./docs/payments-machine-rollout-rfc.md:146:- `docs/tcn-payments-open-questions.md`
./docs/payments-machine-rollout-rfc.md:148:### Phase 2. Single technical path with two payment-owner modes
./docs/payments-machine-rollout-rfc.md:153:- machine-level payment owner resolution
./docs/payments-machine-rollout-rfc.md:165:- real payment test per machine/account context
./docs/payments-machine-rollout-rfc.md:191:6. run one real payment test
./docs/payments-machine-rollout-rfc.md:208:- run one real payment test with their account
./docs/payments-machine-rollout-rfc.md:215:- Clubs mainly care about net outcome and avoiding extra invoicing burden, not about ownership of the payment rails themselves.
./docs/payments-machine-rollout-rfc.md:231:- do not overbuild settlement tooling before real machine payments work
./docs/payments-machine-rollout-rfc.md:234:- one Ballbox-collected machine completes payment flow end to end
./docs/payments-machine-rollout-rfc.md:235:- one tenant-collected machine completes payment flow end to end
./docs/payments-machine-rollout-rfc.md:236:- Ballbox persists approved payments for both owner modes
./docs/payments-machine-rollout-rfc.md:242:- `docs/implemented-payments.md`
./docs/payments-machine-rollout-rfc.md:243:- `docs/tcn-payments-integration-plan.md`
./docs/payments-machine-rollout-rfc.md:244:- `docs/tcn-payments-open-questions.md`
./docs/payments-machine-rollout-rfc.md:245:- `docs/tcn-payments-execution-plan.md`
./docs/payments-machine-rollout-rfc.md:246:- `docs/payments-club-settlement-strategy.md`
./AGENTS.md:33:  - run `pnpm db:migrate --name <slug>` from another terminal while local dev is running
./scripts/revalidate-atc-clubs.ts:140:    await prisma.paymentSession.deleteMany({ where: { vendingMachineId: { in: vendingMachineIds } } });
./components/payments-admin-client.tsx:5:import { parsePaymentExternalReference, type PaymentsAdminData, type SerializedPaymentSession } from "@/lib/payments";
./components/payments-admin-client.tsx:65:const paymentsErrorMessages: Record<string, string> = {
./components/payments-admin-client.tsx:74:  if (error instanceof Error) return paymentsErrorMessages[error.message] ?? error.message;
./components/payments-admin-client.tsx:184:    const response = await fetch("/api/admin/payments/operations", { cache: "no-store" });
./components/payments-admin-client.tsx:233:    const response = await fetch("/api/integrations/payments/orders", {
./components/payments-admin-client.tsx:565:                  <p className="mt-2 text-foreground">Create QR in Ballbox, scan with a real Mercado Pago buyer account, and verify the webhook writes a session to `/admin/payments`.</p>
./components/payments-admin-client.tsx:606:            <h2 className="mt-2 text-2xl font-semibold">Approved payment sessions</h2>
./components/payments-admin-client.tsx:624:          <Input value={form.externalReference} onChange={(event) => setForm((current) => ({ ...current, externalReference: event.target.value }))} placeholder="payment:vendingMachine:txn" disabled={busy} />
./components/payments-admin-client.tsx:631:            placeholder="provider_payment_id"
./components/payments-admin-client.tsx:661:                const response = await fetch("/api/admin/payments/sessions", {
./components/payments-admin-client.tsx:761:                <p className="mt-2">Provider payment id: {item.providerPaymentId ?? "none"}</p>
./components/payments-admin-client.tsx:771:                      const response = await fetch(`/api/admin/payments/sessions/${item.sessionId}`, {
./components/payments-admin-client.tsx:802:                      const response = await fetch(`/api/admin/payments/sessions/${item.sessionId}`, { method: "DELETE" });
./lib/platform.ts:2:  slug: "tvs" | "payments" | "network" | "sports" | "players";
./lib/platform.ts:41:    slug: "payments",
./lib/platform.ts:45:    href: "/admin/payments",
./lib/platform.ts:48:    sourceOfTruth: "ballbox payments module",
./lib/payments.ts:3:const paymentProductPriceMinorById: Record<string, number> = {
./lib/payments.ts:9:  return paymentProductPriceMinorById[productId] ?? null;
./lib/payments.ts:58:  return `payment-vm_${input.vendingMachineId}-txn_${input.machineTxnId}`;
./lib/payments.ts:62:  const match = externalReference.match(/^payment-vm_(.+)-txn_(.+)$/);
./lib/payments.ts:74:    prefix: "payment",
./lib/payments.ts:124:  const payment = await prisma.paymentSession.upsert({
./lib/payments.ts:154:  return payment;
./lib/payments.ts:162:  return prisma.paymentSession.findMany({
./lib/payments.ts:235:  return prisma.paymentSession.create({
./lib/payments.ts:281:  const existing = await prisma.paymentSession.findUnique({
./lib/payments.ts:289:  const next = await prisma.paymentSession.update({
./lib/payments.ts:322:  const existing = await prisma.paymentSession.findUnique({
./lib/payments.ts:329:  await prisma.paymentSession.delete({
./lib/coach-students-backend.ts:38:  paymentStatus: "pending" | "paid";
./lib/coach-students-backend.ts:226:      paymentStatus: "PENDING" | "PAID";
./lib/coach-students-backend.ts:278:    paymentStatus: args.booking.paymentStatus === "PAID" ? "paid" : "pending",
./lib/network-backend-vending-machines.ts:81:          paymentSessions: true,
./lib/network-backend-vending-machines.ts:99:  if (machine._count.paymentSessions > 0) {
./lib/network-backend-vending-machines.ts:103:      paymentSessionCount: machine._count.paymentSessions,
./lib/payments-mercadopago.ts:1:import { getPaymentReferenceCorrelation, type PaymentApprovalCallbackInput } from "@/lib/payments";
./lib/payments-mercadopago.ts:19:  payments?: Array<{
./lib/payments-mercadopago.ts:187:    order.payments?.[0]?.id !== undefined && order.payments?.[0]?.id !== null
./lib/payments-mercadopago.ts:188:      ? String(order.payments[0].id)
./lib/payments-mercadopago.ts:253:        payments: [
./lib/http/payments-route-helpers.ts:2:import { createPaymentsAdminErrorResponse, type PaymentsAdminErrorCode } from "@/lib/contracts/payments";
./lib/http/payments-route-helpers.ts:9:export function paymentsErrorResponse(
./lib/http/payments-route-helpers.ts:17:export function paymentsInvalidPayloadResponse(error: z.ZodError) {
./lib/http/payments-route-helpers.ts:18:  return paymentsErrorResponse("INVALID_PAYLOAD", 400, { issues: error.flatten() });
./lib/http/payments-route-helpers.ts:21:export function paymentsJsonResponse<TBody>(body: TBody, status?: number) {
./lib/http/payments-route-helpers.ts:25:export function paymentsForeignKeyErrorResponse(fieldName: string | null | undefined) {
./lib/http/payments-route-helpers.ts:27:    return paymentsErrorResponse("VENDING_MACHINE_NOT_FOUND", 404);
./lib/http/payments-route-helpers.ts:31:    return paymentsErrorResponse("PRODUCT_NOT_FOUND", 404);
./PLAN.md:14:- Product-separated admin areas exist for `tvs`, `payments`, `network`, `sports`, and `players`.
./PLAN.md:30:- 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.
./PLAN.md:31:- `/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.
./PLAN.md:32:- That admin QR surface is now positioned as a provider test/debug harness; production vending-machine payments should integrate through the TCN machine protocol and let the machine render the buyer-facing QR.
./PLAN.md:34:- Legacy approved-callback compatibility has been removed from operations; `/api/integrations/payments/approved` now returns `410` so only the native MercadoPago path remains active.
./PLAN.md:35:- `/admin/payments` now exposes Ballbox-local CRUD/reconciliation for approved payment sessions plus correlation checks against canonical `payment-vm_{vendingMachineId}-txn_{machineTxnId}` references.
./PLAN.md:46:- 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.
./PLAN.md:118:- `/admin/payments`: payment operations and reconciliation
./PLAN.md:145:  - none required for current native MercadoPago payments flow in Ballbox host app
./PLAN.md:160:- Payments native MercadoPago webhook flow now lands in Ballbox, validates canonical payment correlation from fetched orders, and persists approved payments as `PaymentSession` records.
./PLAN.md:164:- Payments now have an admin CRUD/reconciliation surface over local `PaymentSession` rows via `/api/admin/payments/*`.
./PLAN.md:181:- `/coaches/settings` now opens on a coach workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`, with the calendar rendered as a weekly class matrix for the coach's students and reservation payment status, `Performance` dedicated to club-level results and weekly analytics, and `Horarios y Clases` handling external clubs, weekly availability inside allowed venue/club windows, reusable class packages, and a Mercado Pago placeholder handle with per-section auto-save, but without showing venue fee/court-price internals to the coach.
./PLAN.md:213:- Then continue with `P1` in dependency order: `ARCH-2` and `ARCH-3` -> `PERF-1` -> `PAY-3`.
./PLAN.md:219:   - result: Ballbox created a real production order, received the public webhook at `/notification/mercadopago`, fetched the approved order, and persisted the payment with visible reconciliation evidence in `/admin/payments`
./PLAN.md:221:   - docs: `docs/implemented-payments.md`, `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
./PLAN.md:225:   - docs: `docs/tcn-payments-open-questions.md`, `docs/tcn-payments-integration-plan.md`
./PLAN.md:228:   - docs: `docs/tcn-payments-integration-plan.md`, `docs/tcn-payments-execution-plan.md`
./PLAN.md:229:5. Implement one machine payment flow with two owner modes.
./PLAN.md:232:   - backlog: `PAY-3` in `docs/next-steps-queue.md`
./PLAN.md:233:   - doc: `docs/payments-machine-rollout-rfc.md`
./PLAN.md:236:   - docs: `docs/payments-machine-rollout-rfc.md`, `docs/tcn-payments-execution-plan.md`
./PLAN.md:239:   - doc: `docs/payments-club-settlement-strategy.md`
./PLAN.md:244:- Master rollout RFC: `docs/payments-machine-rollout-rfc.md`
./PLAN.md:245:- Technical implementation RFC: `docs/payments-machine-implementation-rfc.md`
./PLAN.md:246:- Implemented provider layer: `docs/implemented-payments.md`
./PLAN.md:247:- TCN direction and target flow: `docs/tcn-payments-integration-plan.md`
./PLAN.md:248:- Open external questions: `docs/tcn-payments-open-questions.md`
./PLAN.md:249:- Ordered execution sequence: `docs/tcn-payments-execution-plan.md`
./PLAN.md:250:- Club settlement strategy: `docs/payments-club-settlement-strategy.md`
./PLAN.md:252:- TCN protocol evidence artifact: `test-results/tcn-payment-protocol/findings-2026-04-23.md`
./PLAN.md:297:- `/admin/tvs`, `/admin/payments`, `/admin/network`, `/admin/sports`, `/admin/players`, and `/admin/screens` render in Ballbox.
./PLAN.md:303:- Ballbox persists approved payments locally through the native MercadoPago webhook flow at `/notification/mercadopago`.
./PLAN.md:304:- Production vending-machine payments should target a TCN adapter over that native MercadoPago layer; see `docs/tcn-payments-integration-plan.md`.
./lib/contracts/coach-students.ts:53:    paymentStatus: z.enum(["pending", "paid"]).default("pending"),
./lib/contracts/payments.ts:3:const paymentDateTimeSchema = z
./lib/contracts/payments.ts:9:export const paymentSessionStatusSchema = z.enum(["approved"]);
./lib/contracts/payments.ts:11:export const paymentsAdminErrorCodeSchema = z.enum([
./lib/contracts/payments.ts:19:export const paymentsAdminErrorResponseSchema = z
./lib/contracts/payments.ts:22:    error: paymentsAdminErrorCodeSchema,
./lib/contracts/payments.ts:28:export const paymentSessionCreateSchema = z.object({
./lib/contracts/payments.ts:36:  status: paymentSessionStatusSchema.default("approved"),
./lib/contracts/payments.ts:37:  approvedAt: paymentDateTimeSchema,
./lib/contracts/payments.ts:39:  providerPaymentId: z.string().trim().max(191, "Provider payment id is too long").optional(),
./lib/contracts/payments.ts:43:export const paymentApprovalCallbackSchema = z.object({
./lib/contracts/payments.ts:51:  status: paymentSessionStatusSchema,
./lib/contracts/payments.ts:52:  approvedAt: paymentDateTimeSchema,
./lib/contracts/payments.ts:54:  providerPaymentId: z.string().trim().min(1, "Provider payment id is required").max(191, "Provider payment id is too long"),
./lib/contracts/payments.ts:57:export const paymentSessionUpdateSchema = z
./lib/contracts/payments.ts:80:    approvedAt: paymentDateTimeSchema.optional(),
./lib/contracts/payments.ts:82:    providerPaymentId: z.string().trim().max(191, "Provider payment id is too long").optional(),
./lib/contracts/payments.ts:102:export const paymentOrderModeSchema = z.enum(["static", "dynamic", "hybrid"]);
./lib/contracts/payments.ts:104:export const paymentOrderCreateSchema = z.object({
./lib/contracts/payments.ts:109:  mode: paymentOrderModeSchema.default("dynamic"),
./lib/contracts/payments.ts:120:export const paymentOrderCreateResponseSchema = z
./lib/contracts/payments.ts:132:        mode: paymentOrderModeSchema,
./lib/contracts/payments.ts:143:export type PaymentsAdminErrorCode = z.infer<typeof paymentsAdminErrorCodeSchema>;
./lib/contracts/payments.ts:150:  return paymentsAdminErrorResponseSchema.parse({
./lib/admin-overview.ts:15:    paymentSessions,
./lib/admin-overview.ts:16:    paymentVendingMachines,
./lib/admin-overview.ts:28:    prisma.paymentSession.count(),
./lib/admin-overview.ts:29:    prisma.paymentSession.findMany({
./lib/admin-overview.ts:54:    payments: {
./lib/admin-overview.ts:55:      sessions: paymentSessions,
./lib/admin-overview.ts:56:      vendingMachines: paymentVendingMachines.length,
./prisma/schema.prisma:180:  paymentSessions PaymentSession[]
./prisma/schema.prisma:264:  paymentSessions PaymentSession[]
./prisma/schema.prisma:283:  source            String                  @default("ballbox-payment-integration")
./prisma/schema.prisma:384:  paymentStatus   CoachStudentBookingPaymentStatus @default(PENDING)
./prisma/migrations/20260507183000_repair_baselined_schema_drift/migration.sql:61:  "paymentStatus" "CoachStudentBookingPaymentStatus" NOT NULL DEFAULT 'PENDING',
./prisma/migrations/20260506094000_init/migration.sql:235:    "source" TEXT NOT NULL DEFAULT 'ballbox-payment-integration',
./prisma/migrations/20260506094000_init/migration.sql:324:    "paymentStatus" "CoachStudentBookingPaymentStatus" NOT NULL DEFAULT 'PENDING',
./prisma/seed.ts:736:      paymentStatus: "PAID" as const,
./prisma/seed.ts:749:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:762:      paymentStatus: "PAID" as const,
./prisma/seed.ts:775:      paymentStatus: "PAID" as const,
./prisma/seed.ts:788:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:801:      paymentStatus: "PAID" as const,
./prisma/seed.ts:814:      paymentStatus: "PAID" as const,
./prisma/seed.ts:827:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:840:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:853:      paymentStatus: "PAID" as const,
./prisma/seed.ts:866:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:879:      paymentStatus: "PAID" as const,
./prisma/seed.ts:892:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:905:      paymentStatus: "PAID" as const,
./prisma/seed.ts:918:      paymentStatus: "PAID" as const,
./prisma/seed.ts:931:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:944:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:957:      paymentStatus: "PAID" as const,
./prisma/seed.ts:970:      paymentStatus: "PENDING" as const,
./prisma/seed.ts:983:      paymentStatus: "PAID" as const,
./app/notification/mercadopago/route.ts:2:import { recordApprovedPayment, serializePaymentSession } from "@/lib/payments";
./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", {
./tests/unit/payments/mercadopago.test.ts:2:import { extractMercadoPagoOrderId, mapMercadoPagoOrderToApprovedPayment } from "@/lib/payments-mercadopago";
./tests/unit/payments/mercadopago.test.ts:16:  it("maps approved order payload into payment approval callback shape", () => {
./tests/unit/payments/mercadopago.test.ts:20:      external_reference: "payment-vm_vm_001-txn_txn_001",
./tests/unit/payments/mercadopago.test.ts:25:      payments: [{ id: "mp_123" }],
./tests/unit/payments/mercadopago.test.ts:46:      external_reference: "payment-vm_vm_002-txn_txn_002",
./tests/unit/payments/mercadopago.test.ts:50:      payments: [{ id: "mp_456" }],
./tests/e2e/support/cleanup-playwright-db.ts:25:  await prisma.paymentSession.deleteMany({
./app/api/integrations/payments/approved/route.ts:1:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/approved/route.ts:6:  return paymentsJsonResponse(
./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:17:  return paymentsJsonResponse(
./app/api/integrations/payments/orders/route.ts:3:  paymentOrderCreateResponseSchema,
./app/api/integrations/payments/orders/route.ts:4:  paymentOrderCreateSchema,
./app/api/integrations/payments/orders/route.ts:5:} from "@/lib/contracts/payments";
./app/api/integrations/payments/orders/route.ts:8:  paymentsErrorResponse,
./app/api/integrations/payments/orders/route.ts:9:  paymentsInvalidPayloadResponse,
./app/api/integrations/payments/orders/route.ts:10:  paymentsJsonResponse,
./app/api/integrations/payments/orders/route.ts:11:} from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/orders/route.ts:12:import { createMercadoPagoQrOrder, getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/integrations/payments/orders/route.ts:13:import { buildPaymentExternalReference, getPaymentProductDefaultPriceMinor } from "@/lib/payments";
./app/api/integrations/payments/orders/route.ts:23:  return paymentsJsonResponse({
./app/api/integrations/payments/orders/route.ts:39:  const parsedBody = await parsePaymentsJsonBody(request, paymentOrderCreateSchema);
./app/api/integrations/payments/orders/route.ts:41:    return paymentsInvalidPayloadResponse(parsedBody.error);
./app/api/integrations/payments/orders/route.ts:49:    return paymentsErrorResponse("VENDING_MACHINE_NOT_FOUND", 404);
./app/api/integrations/payments/orders/route.ts:57:    return paymentsErrorResponse("PRODUCT_NOT_FOUND", 404);
./app/api/integrations/payments/orders/route.ts:63:    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
./app/api/integrations/payments/orders/route.ts:76:    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
./app/api/integrations/payments/orders/route.ts:83:    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
./app/api/integrations/payments/orders/route.ts:106:    return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 502, {
./app/api/integrations/payments/orders/route.ts:113:  return paymentsJsonResponse(paymentOrderCreateResponseSchema.parse({ ok: true, item: createResult.value }), 201);
./app/api/integrations/payments/health/route.ts:1:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/integrations/payments/health/route.ts:3:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/health/route.ts:9:  return paymentsJsonResponse({
./app/api/integrations/payments/health/route.ts:11:    integration: "payments",
./app/api/integrations/payments/health/route.ts:15:    orderCreatePath: "/api/integrations/payments/orders",
./tests/e2e/payments-admin.spec.ts:7:test("payments admin supports approved session CRUD", async ({ page }) => {
./tests/e2e/payments-admin.spec.ts:14:  await gotoAdmin(page, "/admin/payments");
./tests/e2e/payments-admin.spec.ts:22:  await approvedSessionsSection.getByPlaceholder("payment:vendingMachine:txn").fill(`payment-vm_vm_003-txn_${machineTxnId}`);
./tests/e2e/payments-admin.spec.ts:25:  await approvedSessionsSection.getByPlaceholder("provider_payment_id").fill(`provider-${suffix}`);
./tests/e2e/payments-admin.spec.ts:35:  await sessionArticle.locator("input").nth(3).fill(`payment-vm_vm_003-txn_${machineTxnIdUpdated}`);
./app/api/integrations/payments/sessions/route.ts:1:import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
./app/api/integrations/payments/sessions/route.ts:2:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/integrations/payments/sessions/route.ts:4:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/sessions/route.ts:11:  return paymentsJsonResponse({
./app/api/integrations/payments/sessions/route.ts:13:    integration: "payments",
./app/api/integrations/payments/sessions/route.ts:17:    orderCreatePath: "/api/integrations/payments/orders",
./tests/contracts/payments/auth.test.ts:4:describe("payments integration routes", () => {
./tests/contracts/payments/auth.test.ts:12:    const { GET, POST } = await import("@/app/api/integrations/payments/approved/route");
./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:39:    const { GET } = await import("@/app/api/integrations/payments/health/route");
./tests/contracts/payments/auth.test.ts:47:      orderCreatePath: "/api/integrations/payments/orders",
./tests/contracts/payments/admin-routes.test.ts:4:  paymentsAdminErrorResponseSchema,
./tests/contracts/payments/admin-routes.test.ts:5:} from "@/lib/contracts/payments";
./tests/contracts/payments/admin-routes.test.ts:16:vi.mock("@/lib/payments", () => ({
./tests/contracts/payments/admin-routes.test.ts:30:describe("payments admin routes", () => {
./tests/contracts/payments/admin-routes.test.ts:38:  it("validates payment session create success", async () => {
./tests/contracts/payments/admin-routes.test.ts:43:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
./tests/contracts/payments/admin-routes.test.ts:45:      new Request("http://localhost/api/admin/payments/sessions", {
./tests/contracts/payments/admin-routes.test.ts:53:          externalReference: "payment-vm_vm_001-txn_txn_001",
./tests/contracts/payments/admin-routes.test.ts:68:  it("returns strict 400 for invalid payment create payload", async () => {
./tests/contracts/payments/admin-routes.test.ts:69:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
./tests/contracts/payments/admin-routes.test.ts:71:      new Request("http://localhost/api/admin/payments/sessions", {
./tests/contracts/payments/admin-routes.test.ts:80:    expect(paymentsAdminErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/payments/admin-routes.test.ts:84:  it("returns strict 404 for payment create with missing vending machine", async () => {
./tests/contracts/payments/admin-routes.test.ts:90:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
./tests/contracts/payments/admin-routes.test.ts:92:      new Request("http://localhost/api/admin/payments/sessions", {
./tests/contracts/payments/admin-routes.test.ts:100:          externalReference: "payment-vm_vm_missing-txn_txn_001",
./tests/contracts/payments/admin-routes.test.ts:115:  it("returns strict 404 for payment update with missing product", async () => {
./tests/contracts/payments/admin-routes.test.ts:121:    const { PATCH } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
./tests/contracts/payments/admin-routes.test.ts:123:      new Request("http://localhost/api/admin/payments/sessions/ps_001", {
./tests/contracts/payments/admin-routes.test.ts:136:  it("returns strict 409 for payment update conflict", async () => {
./tests/contracts/payments/admin-routes.test.ts:141:    const { PATCH } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
./tests/contracts/payments/admin-routes.test.ts:143:      new Request("http://localhost/api/admin/payments/sessions/ps_001", {
./tests/contracts/payments/admin-routes.test.ts:156:  it("returns strict 404 for payment delete missing session", async () => {
./tests/contracts/payments/admin-routes.test.ts:159:    const { DELETE } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
./tests/contracts/payments/admin-routes.test.ts:160:    const response = await DELETE(new Request("http://localhost/api/admin/payments/sessions/ps_missing"), {
./tests/contracts/payments/orders-route.test.ts:18:vi.mock("@/lib/payments-mercadopago", () => ({
./tests/contracts/payments/orders-route.test.ts:32:describe("payments order create route", () => {
./tests/contracts/payments/orders-route.test.ts:44:        externalReference: "payment-vm_vm_001-txn_txn_123",
./tests/contracts/payments/orders-route.test.ts:59:    const { POST } = await import("@/app/api/integrations/payments/orders/route");
./tests/contracts/payments/orders-route.test.ts:61:      new Request("http://localhost/api/integrations/payments/orders", {
./tests/contracts/payments/orders-route.test.ts:89:    expect(call.externalReference.startsWith("payment-vm_vm_001-txn_txn_")).toBe(true);
./tests/contracts/payments/orders-route.test.ts:99:        externalReference: "payment-vm_vm_001-txn_txn_124",
./tests/contracts/payments/orders-route.test.ts:114:    const { POST } = await import("@/app/api/integrations/payments/orders/route");
./tests/contracts/payments/orders-route.test.ts:116:      new Request("http://localhost/api/integrations/payments/orders", {
./tests/contracts/network/backend-modules.test.ts:155:  it("blocks vending machine deletion when payment sessions still exist", async () => {
./tests/contracts/network/backend-modules.test.ts:159:      _count: { paymentSessions: 3 },
./tests/contracts/network/backend-modules.test.ts:168:      paymentSessionCount: 3,
./app/api/admin/payments/operations/route.ts:1:import { getPaymentsAdminDataFromDb } from "@/lib/payments";
./app/api/admin/payments/operations/route.ts:2:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/admin/payments/operations/route.ts:5:  return paymentsJsonResponse({
./app/api/admin/payments/sessions/route.ts:7:  paymentSessionCreateSchema,
./app/api/admin/payments/sessions/route.ts:8:} from "@/lib/contracts/payments";
./app/api/admin/payments/sessions/route.ts:9:import { createPaymentSessionInDb, listPaymentSessionsFromDb, serializePaymentSession } from "@/lib/payments";
./app/api/admin/payments/sessions/route.ts:12:  paymentsErrorResponse,
./app/api/admin/payments/sessions/route.ts:13:  paymentsForeignKeyErrorResponse,
./app/api/admin/payments/sessions/route.ts:14:  paymentsInvalidPayloadResponse,
./app/api/admin/payments/sessions/route.ts:15:  paymentsJsonResponse,
./app/api/admin/payments/sessions/route.ts:16:} from "@/lib/http/payments-route-helpers";
./app/api/admin/payments/sessions/route.ts:21:  return paymentsJsonResponse({
./app/api/admin/payments/sessions/route.ts:28:  const parsedBody = await parsePaymentsJsonBody(request, paymentSessionCreateSchema);
./app/api/admin/payments/sessions/route.ts:31:    return paymentsInvalidPayloadResponse(parsedBody.error);
./app/api/admin/payments/sessions/route.ts:36:    return paymentsJsonResponse({ ok: true, item: serializePaymentSession(item) }, 201);
./app/api/admin/payments/sessions/route.ts:39:      return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 409);
./app/api/admin/payments/sessions/route.ts:44:      const response = paymentsForeignKeyErrorResponse(fieldName);
./app/api/admin/payments/sessions/[sessionId]/route.ts:7:  paymentSessionUpdateSchema,
./app/api/admin/payments/sessions/[sessionId]/route.ts:8:} from "@/lib/contracts/payments";
./app/api/admin/payments/sessions/[sessionId]/route.ts:9:import { deletePaymentSessionInDb, serializePaymentSession, updatePaymentSessionInDb } from "@/lib/payments";
./app/api/admin/payments/sessions/[sessionId]/route.ts:12:  paymentsErrorResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:13:  paymentsForeignKeyErrorResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:14:  paymentsInvalidPayloadResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:15:  paymentsJsonResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:16:} from "@/lib/http/payments-route-helpers";
./app/api/admin/payments/sessions/[sessionId]/route.ts:25:  const parsedBody = await parsePaymentsJsonBody(request, paymentSessionUpdateSchema);
./app/api/admin/payments/sessions/[sessionId]/route.ts:28:    return paymentsInvalidPayloadResponse(parsedBody.error);
./app/api/admin/payments/sessions/[sessionId]/route.ts:36:      return paymentsErrorResponse("PAYMENT_SESSION_NOT_FOUND", 404);
./app/api/admin/payments/sessions/[sessionId]/route.ts:39:    return paymentsJsonResponse({ ok: true, item: serializePaymentSession(item) });
./app/api/admin/payments/sessions/[sessionId]/route.ts:42:      return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 409);
./app/api/admin/payments/sessions/[sessionId]/route.ts:47:      const response = paymentsForeignKeyErrorResponse(fieldName);
./app/api/admin/payments/sessions/[sessionId]/route.ts:63:    return paymentsErrorResponse("PAYMENT_SESSION_NOT_FOUND", 404);
./app/api/admin/payments/sessions/[sessionId]/route.ts:66:  return paymentsJsonResponse({ ok: true });
./app/api/admin/summary/route.ts:3:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/admin/summary/route.ts:37:      payments: {
./app/api/admin/summary/route.ts:40:        orderCreatePath: "/api/integrations/payments/orders",
./app/admin/page.tsx:16:    payments: [
./app/admin/page.tsx:17:      { label: "Sessions", value: metrics.payments.sessions },
./app/admin/page.tsx:18:      { label: "Machines", value: metrics.payments.vendingMachines },
./app/admin/layout.tsx:8:  description: "Admin host app for TVs, payments, network, sports, and players.",
./app/admin/payments/page.tsx:1:import { PaymentsAdminClient } from "@/components/payments-admin-client";
./app/admin/payments/page.tsx:2:import { getPaymentsAdminDataFromDb, listPaymentQrOptionsFromDb } from "@/lib/payments";

[Showing lines 349-802 of 802 (50.0KB limit). Full output: /tmp/agent-subagent/pi-bash-3ce9f796852b1e33.log]

[toolResult]
# 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: completed
- Depends on: none
- Scope shipped:
  - public webhook smoke against the native MercadoPago webhook path
  - real payment persisted as approved in `/admin/payments`
  - observed payload quirks identified and handled
- 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 order fetch verification, 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.
- Progress (2026-04-23): TCN protocol review shows the `additional hardware devices` note is about machine-side QR scanning, while the machine payment flow (`FunCode=8000`) already returns `CodeUrl` for machine-screen QR display. This means the admin QR page should be treated as a provider test harness, and production integration should move to a TCN adapter layer.
- Progress (2026-05-05): first production order-create attempt against `https://ballbox.app/api/integrations/payments/orders` failed before buyer payment with Mercado Pago `400 unsupported_properties` because `notification_url` was rejected (`additionalProperties '$.notification_url' not allowed`). Ballbox should stop sending inline `notification_url` for this account/API surface and rely on the existing account-level Mercado Pago webhook configuration before rerunning the real-payment check.
- Closure (2026-05-13): a real approved payment now persists locally and is visible in `/admin/payments`. Mercado Pago webhook noise from `topic_merchant_order_wh` remains intentionally ignored. Closure required two Ballbox compatibility fixes for live provider payloads: product id fallback from `items[0].external_code` and robust parsing of `total_amount` when Mercado Pago returns a string.
- 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: completed
- Depends on: ARCH-1 (done)
- Scope shipped:
  - TVs admin/runtime pages and routes now import direct TVs backend modules instead of `lib/ballbox-store.ts`
  - classes pages/routes/helpers now import `lib/classes-backend.ts` directly for offer/request reads and writes
  - shared TV types now live in `lib/tvs-backend-types.ts`, keeping `lib/ballbox-store.ts` as a compatibility facade instead of the primary entrypoint
- Evidence:
  - `app/admin/tvs/page.tsx`
  - `app/api/admin/tvs/*`
  - `app/api/tvs/heartbeat/route.ts`
  - `app/api/classes/requests/route.ts`
  - `lib/classes.ts`
  - `lib/tvs-source.ts`
- Expected payoff: less coupling and better module discoverability

### ARCH-3 Test taxonomy split
- Priority: P1
- Status: completed
- Depends on: DOC-1 (done)
- Scope shipped:
  - isolated backend/helper tests moved out of `tests/contracts` into `tests/unit`
  - route/schema contract coverage stayed in `tests/contracts`
  - `vitest` layered scripts/includes remain aligned with `contracts`, `unit`, and `integration`
- Evidence:
  - `tests/unit/classes/backend.test.ts`
  - `tests/unit/network/backend-mappers.test.ts`
  - `tests/unit/network/use-network-admin-client-state.test.tsx`
  - `tests/unit/players/use-players-admin-client-state.test.tsx`
  - `tests/unit/sports/backend-events.test.ts`
  - `tests/unit/tvs/runtime.test.ts`
- Expected payoff: faster debugging and more intentional coverage growth

### PERF-1 TTFB focused pass
- Priority: P1
- Status: completed
- Depends on: OPS-1
- Scope shipped:
  - measured production-like local server timings for `/admin`, `/classes`, and `/classes-by-date`
  - removed ATC summary hydration from `/classes` SSR club-name shaping
  - parallelized `/classes-by-date` ATC club/day lookups and added a short shared cache for the assembled calendar
- Evidence:
  - `lib/classes-backend.ts`
  - `lib/coach-availability-matching.ts`
  - local timings moved from roughly `/classes` 3.09s and `/classes-by-date` 6.54s to `/classes` 1.96s and `/classes-by-date` 2.69s on the same Pi
- 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 TCN vending-machine adapter over native MercadoPago
- Priority: P1
- Status: ready
- Depends on: OPS-1, PAY-2
- Scope:
  - define the Ballbox machine-facing adapter for TCN payment init (`FunCode=8000`) over the existing MercadoPago order-create route
  - map TCN `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`, and response `CodeUrl` to Ballbox-native payment fields
  - define the payment-success handshake back to TCN so approved payments can unlock dispense
  - support one shared machine payment flow with two payment-owner modes:
    - `BALLBOX` for club machines that Ballbox collects on
    - `TENANT` for customer-owned collection setups such as Adidas rental machines
  - keep `/admin/payments` positioned as an operator test/debug harness rather than the production vending UX
  - document the exact adapter contract and remaining TCN unknowns before implementation
- Notes:
  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
  - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
  - do not assume extra scanner hardware for the buyer flow; the open question is adapter contract detail, not QR display feasibility
  - club settlement automation can be revisited after the machine-facing flow is stable; manual bookkeeping is the current default
- Expected payoff: closes the actual machine integration path without fragmenting Ballbox collection vs tenant-direct collection into separate technical stacks

## P2 Sequenced Refactors

### PAY-4 Mercado Pago native split for club revenue share
- Priority: P2
- Status: blocked
- Depends on: OPS-1, PAY-2, PAY-3
- Scope:
  - keep scheduled manual settlements as the current operating default for Ballbox-collected club machines
  - document Mercado Pago native split as the only future automation path under consideration for club payouts
  - if automation is ever chosen, define onboarding, credential storage, rotation, and support rules before implementation
  - capture the trigger conditions that justify leaving manual bookkeeping behind
- Notes:
  - Ballbox currently runs QR vending payments through the native Mercado Pago order/webhook flow, so split automation is a separate future decision
  - first-wave rollout uses mixed owner modes (`BALLBOX` + `TENANT`), and settlement work only applies to the `BALLBOX` side
  - keep this behind real-world payments validation first (`OPS-1`) and machine-facing TCN adapter definition (`PAY-3`)
- Expected payoff: a clear path from manual settlements to a future Mercado Pago split if the machine flow and business volume justify it

### 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:

[4 more lines in file. Use offset=221 to continue.]

[toolResult]
# 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.
- 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.
- That admin QR surface is now positioned as a provider test/debug harness; production vending-machine payments should integrate through the TCN machine protocol and let the machine render the buyer-facing QR.
- Club revenue share is currently handled with scheduled manual settlements/bookkeeping; Mercado Pago native split stays deferred until the machine flow and club count justify it.
- 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.
- `/classes` SSR no longer waits on ATC sportclub-summary hydration just to print club names, and `/classes-by-date` now parallelizes club/date ATC availability lookups behind a short shared server cache so first-byte time stays materially lower under production-like loads.
- 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 workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`: calendar shows the current week in an hour-by-hour matrix centered on the coach's real student classes, grouping simultaneous reservations into one class card, surfacing participant basics (name, sexo, edad, categoria), and marking paid vs pending reservations, and now starts with a large `Agregar clase` CTA that jumps into `Alumnos`; `Performance` shows per-club performance plus weekly earnings and student consistency/review rows, `Alumnos` keeps roster + taken-hour management with the student table visible by default and `Agregar clase` opening a wide inline composer instead of a right-side summary panel, and `Horarios y Clases` now auto-saves club selection, availability, packages, and Mercado Pago setup without explicit save buttons while no longer exposing the club's court-fee pricing to the coach; pending autosave writes are now also flushed if the user leaves the page before the debounce finishes.
- `/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.
- `/clubs/coaches` now lands on a club workspace with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: calendar now works one sede at a time, switches between `Disponibilidad` and `Reservas`, uses clickable tag filters for sede/day/club-owned coaches instead of dropdowns, assigns a persistent color to each coach-enabled court, and shows the selected day with all coach-enabled courts as columns so clubs can see coverage or reservation gaps per court without selecting a court filter; coaches assigned with `Asignar todas las canchas` are treated as flexible coverage and occupy one free court per half-hour, not every court at once; manual class loading and pending requests open from dedicated action buttons above the calendar, and manual class creation now requires selecting an active coach package so clubs cannot create classes for coaches without packages; analytics shows professor/reservation money splits and review placeholders, `Sedes` now starts from a large `Agregar sede` CTA, keeps venue creation behind that action, and lists every venue in an editable table with a deep edit action, and `Profesores` groups invite links plus the club coach table.
- The `/clubs/coaches` calendar cells now avoid repeating redundant court-color bars or per-slot coverage counts inside each block; court color remains visible in the column header and the empty/cubierto cell state carries the coverage signal.
- Club workspace clickable actions are now visually distinguished with stronger accent borders, CTA chips, and hover/focus states, while venue detail edits auto-save in place instead of requiring explicit `Guardar` buttons or reloading the user back to the top, and pending autosave writes now also flush on navigation/unmount so venue/price/schedule edits are not lost when switching screens quickly.
- Club roster reads now tolerate production databases that have not yet received the newest coach/student schema additions: missing `Coach.assignedCourtId`, `CoachStudentBooking`, player contact columns, or coach package tables degrade to empty/null data instead of turning `/api/clubs/coaches` into a 500.
- `/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.
- `/api/coaches/students` now also supports weekly recurring student bookings, and the coach workspace `Alumnos` section can create new students directly before assigning them to one-off or recurring classes.
- `/api/clubs/players` now lets a club create its own students and auto-membership them into that club before assigning them to a professor from the club workspace manual class flow.
- External-coach availability now excludes venue slots already absorbed by club-coach availability, so external coaches only see residual hours after club coaches take priority.
- `/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, including which fixed court each coach covers in that sede; the page is now structured as a visible step-by-step workspace with quick jump cards so coach hours and court selection are no longer hidden inside collapsed panels.
- `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.
- Isolated backend/helper tests for classes, network client helpers, players client helpers, sports backend modules, and TVs runtime invariants now live under `tests/unit`, while route/schema surfaces stay under `tests/contracts`.

## 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 workspace with `Calendar` / `Performance` / `Alumnos` / `Horarios y Clases`
- `/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.
- For the current Ballbox Mercado Pago account/API surface, QR order creation now relies on the account-level webhook configuration instead of sending inline `notification_url`, because Mercado Pago rejects that field on `POST /v1/orders` for this setup.
- 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 coach workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`, with the calendar rendered as a weekly class matrix for the coach's students and reservation payment status, `Performance` dedicated to club-level results and weekly analytics, and `Horarios y Clases` handling external clubs, weekly availability inside allowed venue/club windows, reusable class packages, and a Mercado Pago placeholder handle with per-section auto-save, but without showing venue fee/court-price internals to the coach.
- `/clubs/venues/[venueId]` now also supports recurring hourly price overrides on top of the venue base reservation price, and `/clubs/coaches` calendar cells now surface the effective slot price so clubs can see both coverage/reservations and what that half-hour costs.
- `/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 venue management now persists an optional assigned court per coach inside the venue: a specific court pins coverage to that court, while the default `Asignar todas las canchas` mode lets that coach float across enabled courts and be placed into one free court per half-hour in the club calendar.
- 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.
- Classes route/page callers now also import `lib/classes-backend.ts` directly for offer/request reads and writes, so class surfaces no longer depend on `lib/ballbox-store.ts` as their runtime entrypoint.
- 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.
- TVs admin/runtime route and page callers now import direct TVs backend modules plus shared TV types from `lib/tvs-backend-types.ts`, so `lib/ballbox-store.ts` is now a compatibility facade instead of the primary TVs entrypoint.
- 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-2` TVs runtime parity closure.
- Immediately after `OPS-2`: continue with `P1` dependency order.
- Then continue with `P1` in dependency order: `ARCH-2` and `ARCH-3` -> `PERF-1` -> `PAY-3`.
- Club revenue share is currently handled with scheduled manual settlements; `PAY-4` stays behind the TCN machine-facing adapter work as a future Mercado Pago split option.
- Keep detailed backlog/status only in `docs/next-steps-queue.md` to avoid drift.

## Ordered Payments Track
1. Provider-level Mercado Pago proof without TCN hardware is now closed.
   - result: Ballbox created a real production order, received the public webhook at `/notification/mercadopago`, fetched the approved order, and persisted the payment with visible reconciliation evidence in `/admin/payments`
   - notable fixes during closure: Mercado Pago order mapping now accepts `items[0].external_code` as product id fallback and parses `total_amount` from either string or number
   - docs: `docs/implemented-payments.md`, `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
2. After `OPS-1`, treat provider risk as materially reduced but not machine risk.
   - still required for machine confidence: real TCN `8000` request validation, `CodeUrl` rendering confirmation, approval-to-dispense handshake proof, duplicate/retry handling, timeout/expiry handling, and an operator runbook for approved-but-no-vend incidents
3. Ask TCN sales to confirm the remaining machine-contract questions with their engineers.
   - docs: `docs/tcn-payments-open-questions.md`, `docs/tcn-payments-integration-plan.md`
   - note: this is required because the machines are not here yet
4. Freeze the Ballbox <-> TCN adapter contract once those answers come back.
   - docs: `docs/tcn-payments-integration-plan.md`, `docs/tcn-payments-execution-plan.md`
5. Implement one machine payment flow with two owner modes.
   - `BALLBOX`: Ballbox-collected club machines
   - `TENANT`: tenant/client-collected machines such as Adidas rentals
   - backlog: `PAY-3` in `docs/next-steps-queue.md`
   - doc: `docs/payments-machine-rollout-rfc.md`
6. Use guided onboarding for the first wave of 5 machines.
   - first wave target: 3 `BALLBOX` machines + 2 `TENANT` machines
   - docs: `docs/payments-machine-rollout-rfc.md`, `docs/tcn-payments-execution-plan.md`
7. Keep club settlements informal/manual for Ballbox-collected machines until the machine path is stable and settlement pain is real.
   - backlog: `PAY-4` in `docs/next-steps-queue.md`
   - doc: `docs/payments-club-settlement-strategy.md`
8. Revisit automation later only if volume or ops pain justify it.
   - current future candidate: Mercado Pago split / marketplace

### Payments Track Docs
- Master rollout RFC: `docs/payments-machine-rollout-rfc.md`
- Technical implementation RFC: `docs/payments-machine-implementation-rfc.md`
- Implemented provider layer: `docs/implemented-payments.md`
- TCN direction and target flow: `docs/tcn-payments-integration-plan.md`
- Open external questions: `docs/tcn-payments-open-questions.md`
- Ordered execution sequence: `docs/tcn-payments-execution-plan.md`
- Club settlement strategy: `docs/payments-club-settlement-strategy.md`
- Current Mercado Pago webhook blocker/evidence: `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
- TCN protocol evidence artifact: `test-results/tcn-payment-protocol/findings-2026-04-23.md`

### 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.

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

[toolResult]
# 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`
- `docs/payments-machine-rollout-rfc.md`
- `docs/payments-machine-implementation-rfc.md`
- `docs/tcn-payments-integration-plan.md`
- `docs/tcn-payments-open-questions.md`
- `docs/tcn-payments-execution-plan.md`
- `docs/payments-club-settlement-strategy.md`

[toolResult]
# Payments machine implementation RFC

## Status
- Proposed technical implementation slice for `PAY-3`.
- Builds on `docs/payments-machine-rollout-rfc.md`.

## Goal
Implement one machine-payment flow in Ballbox that:
- serves TCN machines
- creates Mercado Pago QR orders
- persists approved payments through the Ballbox webhook
- supports two payment-owner modes without forking the stack:
  - `BALLBOX`
  - `TENANT`

## Non-goals
- full split settlement automation
- tenant self-serve onboarding
- final long-term tenant account admin UX
- production-grade accounting ledger in this slice

## Current code baseline
Existing implemented pieces:
- order create route: `app/api/integrations/payments/orders/route.ts`
- webhook intake: `app/notification/mercadopago/route.ts`
- provider helper: `lib/payments-mercadopago.ts`
- payment persistence + correlation: `lib/payments.ts`
- payment contracts: `lib/contracts/payments.ts`

Current limitation:
- provider config is effectively global via Ballbox env (`BALLBOX_MERCADOPAGO_*`)
- webhook fetch path currently assumes one Mercado Pago config
- machine-facing TCN adapter does not exist yet

## Proposed architecture

### 1. Separate provider config resolution from raw env access
Create a resolver layer that answers:
- which payment-owner mode applies to this machine/request/order
- which Mercado Pago config should be used

Proposed concept:
- `lib/payments-mercadopago-config.ts`

Responsibilities:
- resolve `BALLBOX` default config from env
- resolve `TENANT` config from a Ballbox-owned machine/tenant mapping source
- return a normalized config object used by both order-create and webhook-fetch flows

Suggested shape:
```ts
export type PaymentOwnerMode = "BALLBOX" | "TENANT";

export type MercadoPagoConfigResolved = {
  ownerMode: PaymentOwnerMode;
  ownerKey: string; // e.g. ballbox-default or tenant/adidas-001
  accessToken: string;
  webhookSecret: string | null;
  externalPosId: string | null;
  publicApiBaseUrl: string;
};
```

## 2. Resolve payment owner per machine
Ballbox needs a deterministic way to know which owner mode applies before creating an order.

Short-term recommendation:
- add machine-level payment ownership fields to the Ballbox data model
- keep it simple and explicit, not inferred from club type or naming

Suggested fields at machine level:
- `paymentOwnerMode`
- `paymentOwnerKey`
- optional override `paymentExternalPosId`

Meaning:
- `paymentOwnerMode`: `BALLBOX` or `TENANT`
- `paymentOwnerKey`: resolver key for the MP config to use
- `paymentExternalPosId`: optional machine-specific override when not using the owner default POS

If schema change should stay smaller in step 1, fallback is:
- temporary in-code or env-backed mapping by `vendingMachineId`
- but only as a bridge; repo docs should still target DB-backed ownership

## 3. Preserve owner resolution in the order correlation path
Webhook processing must re-discover the correct MP credentials later.

Today Ballbox correlation key is:
- `payment-vm_{vendingMachineId}-txn_{machineTxnId}`

Keep that canonical reference.
But add enough persistence so webhook-triggered fetch can derive config without guessing.

Recommended minimum persisted fields on payment/session side:
- `vendingMachineId`
- canonical `externalReference`
- provider order id
- `paymentOwnerMode`
- `paymentOwnerKey`

If `PaymentSession` should remain approved-only and lean, add a lightweight pre-approval payment operation record instead.

## 4. Introduce a pre-approval payment operation record
Reason:
- webhook can arrive before a final approved `PaymentSession` exists
- Ballbox needs a stable place to remember which config created which provider order

Recommendation:
- add a lightweight table for initiated machine payments

Suggested record name:
- `PaymentOperation`

Suggested fields:
- `id`
- `vendingMachineId`
- `machineTxnId`
- `externalReference`
- `provider` (`MERCADOPAGO`)
- `providerOrderId`
- `paymentOwnerMode`
- `paymentOwnerKey`
- `externalPosId`
- `amountMinor`
- `status` (`CREATED`, `APPROVED`, `FAILED`, `EXPIRED`)
- `tenantLabel` or `notes` optional
- timestamps

Why this helps:
- webhook can resolve order id -> `PaymentOperation` -> correct MP config
- Ballbox can detect duplicate machine requests by `externalReference`
- later vend/result handling has a more natural source row than only approved sessions

## 5. Refactor provider helpers to accept explicit config
Current helpers mostly read env internally.
That blocks multi-owner support.

Refactor target:
- `fetchMercadoPagoOrder(orderId, config)`
- `createMercadoPagoQrOrder(input, config)`
- `getMercadoPagoConfig()` becomes only the `BALLBOX` default resolver or disappears behind the resolver module

Rule:
- business flows decide config first
- provider helpers never decide owner mode themselves

## 6. Add a machine-facing service layer before routes
Suggested module:
- `lib/payments-machine-flow.ts`

Responsibilities:
- resolve machine owner mode/config
- build canonical external reference
- create or reuse payment operation row
- call Mercado Pago order create
- return Ballbox-normalized result for either admin or TCN routes

Suggested main functions:
```ts
createMachinePaymentSession({
  vendingMachineId,
  machineTxnId,
  amountMinor,
  productId,
  source, // admin | tcn
})

resolvePaymentOwnerForMachine(vendingMachineId)

recordWebhookApproval({ orderId, externalReference, ... })
```

## 7. TCN route should sit on top of the machine-payment service
Recommendation:
- do not embed MP logic directly in TCN route handlers
- TCN route should translate between TCN contract and Ballbox machine-payment service only

Proposed route family:
- `app/api/integrations/tcn/payments/init/route.ts` or similar

That route should:
- parse TCN request
- resolve machine + amount + trade number
- call machine-payment service
- return TCN response shape including `CodeUrl`

## 8. Keep admin QR route as test harness over the same service
Admin route should reuse the same service layer where possible.
That avoids a separate payment stack for admin testing vs machine runtime.

Result:
- `/api/integrations/payments/orders` becomes a Ballbox test/operator entrypoint
- TCN adapter becomes the real machine entrypoint
- both converge on one machine-payment service underneath

## Data model recommendation

### Preferred
Add machine ownership to `VendingMachine` and payment initiation state to a new `PaymentOperation` table.

### Why preferred
- machine-level owner mode is operational truth
- webhook correlation becomes reliable
- future reporting becomes easier
- tenant setup does not need route-local hacks or env naming tricks

### Minimum viable fallback if schema must wait
- machine owner config stored in a local mapping module keyed by `vendingMachineId`
- provider order id + owner key persisted in a file/temporary table substitute

This fallback is weaker and should be temporary only.

## Webhook resolution strategy

### Preferred path
1. webhook payload yields `orderId`
2. Ballbox tries to find `PaymentOperation` by `providerOrderId`
3. from that row Ballbox gets `paymentOwnerMode` + `paymentOwnerKey`
4. Ballbox resolves MP config
5. Ballbox fetches order from MP
6. Ballbox maps approved result
7. Ballbox persists/updates `PaymentSession`
8. Ballbox marks `PaymentOperation` as approved

### Fallback path if operation row missing
1. parse `externalReference` from fetched order
2. derive `vendingMachineId`
3. resolve machine owner mode from DB
4. retry or continue

Use fallback only as recovery, not primary mechanism.

## Idempotency rules
- unique key on canonical `externalReference`
- unique key on `providerOrderId` if possible
- repeated machine `TradeNo` for same machine should reuse or safely reject duplicate order creation
- webhook replays should be no-op once the approval is persisted

## Suggested implementation steps

### Step A
Refactor provider helpers to take explicit config.

### Step B
Add owner resolution layer.

### Step C
Add machine-payment service layer.

### Step D
Add `PaymentOperation` persistence.

### Step E
Move `/api/integrations/payments/orders` onto that service.

### Step F
Add TCN adapter route using the same service.

### Step G
Update webhook route to resolve config from `PaymentOperation` first.

## Testing plan

### Contracts
- order create still validates canonical references
- TCN init route returns expected TCN response shape
- webhook route is idempotent with repeated notifications

### Unit
- owner resolution by machine
- config resolution by owner key
- fallback correlation rules
- duplicate `TradeNo` behavior

### Integration
- `BALLBOX` machine order create -> webhook persist
- `TENANT` machine order create -> webhook persist
- webhook config resolution by `providerOrderId`

## Open questions still external
- exact TCN `CodeUrl` payload expectation
- exact TCN payment-success / dispense handshake
- whether tenant webhook/POS setup needs any special MP product variation

## Recommendation
Implement `PAY-3` around:
- explicit owner resolution
- explicit provider config resolution
- a shared machine-payment service
- a lightweight `PaymentOperation` table

That is the smallest clean shape that supports both first-wave modes without painting Ballbox into a corner.

## Related docs
- `docs/payments-machine-rollout-rfc.md`
- `docs/tcn-payments-integration-plan.md`
- `docs/tcn-payments-execution-plan.md`
- `docs/implemented-payments.md`

[toolResult]
# TCN payments integration plan

## Decision
- Treat the current `/admin/payments` QR generator as an operator test harness, not the final production payment UX for vending machines.
- Production vending flow should integrate with the TCN machine protocol and let the machine render the buyer-facing QR on its own screen.

## Why
- TCN protocol `2.5V` states: `to scan QR codes additional hardware devices are necessary.`
- That wording applies to **machine-side QR scanning**, not to the machine displaying a QR on its screen for the buyer to scan with a phone.
- The payment section (`2.1.5 Integral exchange`, `FunCode=8000`) already defines a dynamic payment flow:
  - machine sends `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`
  - server returns `CodeUrl`, `TradeNo`
- `CodeUrl` is described as the returned value used to generate the QR code.
- That is enough to proceed with integration planning without assuming extra scanner hardware.

## Intended production flow
1. Buyer selects product on TCN machine.
2. TCN machine requests Ballbox payment session using the TCN payment contract.
3. Ballbox creates a Mercado Pago QR order.
4. Ballbox maps the Mercado Pago output into the TCN response shape and returns `CodeUrl`.
5. TCN machine renders the QR on its screen.
6. Buyer scans with Mercado Pago app on phone.
7. Payment provider notifies Ballbox webhook.
8. Ballbox validates/persists approved payment.
9. TCN machine receives the success signal through the TCN-side notify/result path and dispenses product.

## Ballbox work now
### 1. Lock the machine-facing contract
- Define a Ballbox route for TCN `8000` payment-init requests.
- Define exact request/response mapping for:
  - `MachineID` -> Ballbox `vendingMachineId` or external machine identifier
  - `TradeNo` -> machine transaction id / canonical `externalReference`
  - `SlotNo`
  - `Price`
  - `NotityUrl`
  - response `CodeUrl`
- Confirm whether TCN expects raw payment URL, QR payload string, or a specific provider deep link in `CodeUrl`.

### 2. Define result/dispense handshake
- Map how Ballbox should signal payment success back into the TCN flow.
- Verify whether Ballbox must call `NotityUrl`, expose a callback endpoint, or rely on a polling/result interface in the broader TCN protocol set.
- Confirm how TCN links approved payment to dispense using `TradeNo` and/or `SlotNo`.

### 3. Normalize machine identity
- Decide the canonical mapping between TCN `MachineID` and Ballbox `VendingMachine`.
- Add any missing external-id field only if needed for clean lookup.

### 4. Keep admin QR page as a test tool
- `/admin/payments` remains useful for:
  - Mercado Pago config checks
  - live QR creation smoke tests
  - webhook validation
  - manual provider debugging
- Do not frame it as the final vending-machine payment UX.

## Unknowns still to close
- Exact payload expected inside TCN `CodeUrl`.
- Exact success callback / notify shape between Ballbox and TCN after payment approval.
- Whether machine-side polling (`4000`) participates in payment completion or only app-driven vend/load flows.
- Whether TCN needs additional payment-status endpoints beyond `8000` for a full dispense flow.

## Recommendation
- Next implementation slice should be a TCN adapter spec + stub endpoints, not more work on the standalone admin QR page.
- Keep Mercado Pago-native Ballbox routes as the provider layer underneath that adapter.

## Evidence
- `test-results/tcn-payment-protocol/Protocol 2.5V English version.pdf`
- `test-results/tcn-payment-protocol/findings-2026-04-23.md`

[toolResult]
# TCN payments open questions

## Goal
Close the remaining machine-integration unknowns before Ballbox implements the full TCN adapter.

## Questions for TCN sales / engineering
1. In `FunCode=8000`, what exact value should Ballbox return in `CodeUrl`?
   - raw URL
   - QR payload string
   - Mercado Pago deep link
   - other provider-specific format
2. After payment approval, how does the machine learn that the vend can proceed?
   - Ballbox calls the request `NotityUrl`
   - machine polls another TCN endpoint
   - cloud pushes through `4000`
   - other flow
3. Is `NotityUrl` always machine-provided per transaction, or is there also a machine-level configured callback?
4. What is the exact payload shape expected for the payment-success notify/result message?
5. Which field is the true dispense correlation key?
   - `TradeNo`
   - `SlotNo`
   - both
   - another internal machine field
6. Does the stock TCN app render the QR natively after a successful `8000` response, or is any machine-side UI configuration required?
7. Are there any timeout / expiry expectations for the QR payment window from the machine side?
8. What should Ballbox return to the machine when payment creation fails or times out?
9. Is there any machine retry behavior Ballbox must tolerate for duplicate `TradeNo` requests?
10. Are there additional payment-related endpoints beyond `8000` required for a production dispense flow?

## Questions for Mercado Pago / internal validation
1. Which returned value from Ballbox should be safest to map into TCN `CodeUrl` for dynamic QR display?
2. Does current Ballbox order creation produce the same QR/deeplink payload shape expected by a buyer using Mercado Pago app?
3. Is the current webhook exposure stable enough for real public notifications on the production-like Ballbox URL?
4. Are there provider-side expiry, duplicate-order, or idempotency constraints we should encode before machine rollout?

## Output expected from this doc
- A resolved field-mapping note added back into `docs/tcn-payments-integration-plan.md`
- A concrete implementation spec once answers arrive

[toolResult]
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 { 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 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,
  });

  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);
}

[toolResult]
import { prisma } from "@/lib/prisma";

const paymentProductPriceMinorById: Record<string, number> = {
  product_tubo_pelotas: 100000,
  product_pack_premium: 250000,
};

export function getPaymentProductDefaultPriceMinor(productId: string) {
  return paymentProductPriceMinorById[productId] ?? null;
}

export type PaymentApprovalCallbackInput = {
  sessionId: string;
  vendingMachineId: string;
  productId: string;
  machineTxnId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  status: "approved";
  approvedAt: string;
  lateApproval: boolean;
  providerPaymentId: string;
};

type PaymentSessionRecord = Awaited<ReturnType<typeof listPaymentSessionsFromDb>>[number];

function buildRawPaymentPayload(input: {
  sessionId: string;
  vendingMachineId: string;
  productId: string;
  machineTxnId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  approvedAt: string;
  lateApproval: boolean;
  providerPaymentId?: string | null;
  source: string;
}) {
  return {
    sessionId: input.sessionId,
    vendingMachineId: input.vendingMachineId,
    productId: input.productId,
    machineTxnId: input.machineTxnId,
    externalReference: input.externalReference,
    amountMinor: input.amountMinor,
    currency: input.currency,
    status: "approved",
    approvedAt: input.approvedAt,
    lateApproval: input.lateApproval,
    providerPaymentId: input.providerPaymentId ?? null,
    source: input.source,
  };
}

export function buildPaymentExternalReference(input: { vendingMachineId: string; machineTxnId: string }) {
  return `payment-vm_${input.vendingMachineId}-txn_${input.machineTxnId}`;
}

export function parsePaymentExternalReference(externalReference: string) {
  const match = externalReference.match(/^payment-vm_(.+)-txn_(.+)$/);

  if (!match) {
    return null;
  }

  const [, vendingMachineId, machineTxnId] = match;
  if (!vendingMachineId || !machineTxnId) {
    return null;
  }

  return {
    prefix: "payment",
    vendingMachineId,
    machineTxnId,
  };
}

export function getPaymentReferenceCorrelation(input: {
  externalReference: string;
  vendingMachineId: string;
  machineTxnId: string;
}) {
  const parsedReference = parsePaymentExternalReference(input.externalReference);

  return {
    parsedReference,
    externalReferenceIsCanonical: parsedReference !== null,
    vendingMachineIdMatchesReference: parsedReference ? parsedReference.vendingMachineId === input.vendingMachineId : false,
    machineTxnIdMatchesReference: parsedReference ? parsedReference.machineTxnId === input.machineTxnId : false,
  };
}

export function serializePaymentSession(item: PaymentSessionRecord) {
  const correlation = getPaymentReferenceCorrelation({
    externalReference: item.externalReference,
    vendingMachineId: item.vendingMachineId,
    machineTxnId: item.machineTxnId,
  });

  return {
    sessionId: item.id,
    vendingMachineId: item.vendingMachineId,
    productId: item.productId,
    machineTxnId: item.machineTxnId,
    externalReference: item.externalReference,
    amountMinor: item.amountMinor,
    currency: item.currency,
    status: item.status.toLowerCase(),
    approvedAt: item.approvedAt.toISOString(),
    lateApproval: item.lateApproval,
    providerPaymentId: item.providerPaymentId,
    source: item.source,
    correlation,
  };
}

export type SerializedPaymentSession = ReturnType<typeof serializePaymentSession>;

export async function recordApprovedPayment(input: PaymentApprovalCallbackInput) {
  const approvedAt = new Date(input.approvedAt);

  const payment = await prisma.paymentSession.upsert({
    where: { id: input.sessionId },
    update: {
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId: input.providerPaymentId,
      rawPayload: input,
    },
    create: {
      id: input.sessionId,
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      status: "APPROVED",
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId: input.providerPaymentId,
      rawPayload: input,
    },
  });

  return payment;
}

export async function listRecentApprovedPayments(limit = 20) {
  return listPaymentSessionsFromDb(limit);
}

export async function listPaymentSessionsFromDb(limit = 50) {
  return prisma.paymentSession.findMany({
    orderBy: { approvedAt: "desc" },
    take: limit,
  });
}

export async function listPaymentQrOptionsFromDb() {
  const [products, vendingMachines] = await Promise.all([
    prisma.product.findMany({
      orderBy: { name: "asc" },
      select: { id: true, name: true },
    }),
    prisma.vendingMachine.findMany({
      orderBy: { label: "asc" },
      select: { id: true, label: true, venueId: true },
    }),
  ]);

  return {
    products: products.map((product) => ({
      ...product,
      priceMinor: getPaymentProductDefaultPriceMinor(product.id),
    })),
    vendingMachines,
  };
}

export async function getPaymentsAdminDataFromDb() {
  const items = await listPaymentSessionsFromDb();
  const serializedItems = items.map(serializePaymentSession);
  const mismatchCount = serializedItems.filter(
    (item) => !item.correlation.vendingMachineIdMatchesReference || !item.correlation.machineTxnIdMatchesReference
  ).length;
  const amountTotals = Array.from(
    items.reduce((totals, item) => {
      totals.set(item.currency, (totals.get(item.currency) ?? 0) + item.amountMinor);
      return totals;
    }, new Map<string, number>())
  ).map(([currency, amountMinor]) => ({
    currency,
    amountMinor,
  }));

  return {
    source: "ballbox postgres",
    summary: {
      sessions: items.length,
      vendingMachines: new Set(items.map((item) => item.vendingMachineId)).size,
      lateApprovals: items.filter((item) => item.lateApproval).length,
      correlationMismatches: mismatchCount,
      amountTotals,
    },
    items: serializedItems,
  };
}

export type PaymentsAdminData = Awaited<ReturnType<typeof getPaymentsAdminDataFromDb>>;

export async function createPaymentSessionInDb(input: {
  sessionId: string;
  vendingMachineId: string;
  productId: string;
  machineTxnId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  approvedAt: string;
  lateApproval: boolean;
  providerPaymentId?: string;
  source: string;
}) {
  const approvedAt = new Date(input.approvedAt);

  return prisma.paymentSession.create({
    data: {
      id: input.sessionId,
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      status: "APPROVED",
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId: input.providerPaymentId?.trim() ? input.providerPaymentId : null,
      source: input.source,
      rawPayload: buildRawPaymentPayload({
        sessionId: input.sessionId,
        vendingMachineId: input.vendingMachineId,
        productId: input.productId,
        machineTxnId: input.machineTxnId,
        externalReference: input.externalReference,
        amountMinor: input.amountMinor,
        currency: input.currency,
        approvedAt: approvedAt.toISOString(),
        lateApproval: input.lateApproval,
        providerPaymentId: input.providerPaymentId,
        source: input.source,
      }),
    },
  });
}

export async function updatePaymentSessionInDb(
  sessionId: string,
  input: {
    vendingMachineId?: string;
    productId?: string;
    machineTxnId?: string;
    externalReference?: string;
    amountMinor?: number;
    currency?: string;
    approvedAt?: string;
    lateApproval?: boolean;
    providerPaymentId?: string;
    source?: string;
  }
) {
  const existing = await prisma.paymentSession.findUnique({
    where: { id: sessionId },
  });

  if (!existing) return null;

  const approvedAt = input.approvedAt ? new Date(input.approvedAt) : existing.approvedAt;
  const providerPaymentId = input.providerPaymentId !== undefined ? input.providerPaymentId.trim() || null : existing.providerPaymentId;
  const next = await prisma.paymentSession.update({
    where: { id: sessionId },
    data: {
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId,
      source: input.source,
      rawPayload: buildRawPaymentPayload({
        sessionId,
        vendingMachineId: input.vendingMachineId ?? existing.vendingMachineId,
        productId: input.productId ?? existing.productId,
        machineTxnId: input.machineTxnId ?? existing.machineTxnId,
        externalReference: input.externalReference ?? existing.externalReference,
        amountMinor: input.amountMinor ?? existing.amountMinor,
        currency: input.currency ?? existing.currency,
        approvedAt: approvedAt.toISOString(),
        lateApproval: input.lateApproval ?? existing.lateApproval,
        providerPaymentId,
        source: input.source ?? existing.source,
      }),
    },
  });

  return next;
}

export async function deletePaymentSessionInDb(sessionId: string) {
  const existing = await prisma.paymentSession.findUnique({
    where: { id: sessionId },
    select: { id: true },
  });

  if (!existing) return false;

  await prisma.paymentSession.delete({
    where: { id: sessionId },
  });

  return true;
}

[toolResult]
  dayOfWeek Int
  startTime String
  endTime   String
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  @@index([venueId, dayOfWeek])
}

model VenueCoachPriceOverride {
  id        String   @id @default(cuid())
  venue     Venue    @relation(fields: [venueId], references: [id])
  venueId   String
  dayOfWeek Int
  startTime String
  endTime   String
  price     Int
  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?
  assignedCourt   VenueCourt?   @relation(fields: [assignedCourtId], references: [id])
  assignedCourtId 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])

[156 more lines in file. Use offset=330 to continue.]

[toolResult]
# TCN payments execution plan

## Purpose
Ordered execution plan for getting from current Ballbox native Mercado Pago support to real TCN machine payments.

## Step 1. Finish provider-layer proof
- Goal: prove Ballbox native Mercado Pago flow works end to end without TCN hardware.
- Inputs:
  - `docs/implemented-payments.md`
  - `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
- Tasks:
  - validate public webhook reachability for `/notification/mercadopago`
  - run one real approved payment
  - confirm local `PaymentSession` persistence in `/admin/payments`
  - document exact payload and public URL evidence
- Exit criterion:
  - one real approved payment persists locally through webhook flow

## Step 2. Ask TCN the machine-contract questions
- Goal: close the remaining machine-side unknowns before coding the adapter.
- Inputs:
  - `docs/tcn-payments-integration-plan.md`
  - `docs/tcn-payments-open-questions.md`
  - `test-results/tcn-payment-protocol/findings-2026-04-23.md`
- Tasks:
  - send the question list to the TCN sales contact
  - ask them to confirm with engineers
  - capture answers in repo docs
- Exit criterion:
  - `CodeUrl` meaning and payment-success handshake are confirmed

## Step 3. Freeze Ballbox-TCN contract
- Goal: convert answers into a concrete adapter spec.
- Inputs:
  - `docs/tcn-payments-integration-plan.md`
  - answers captured from Step 2
- Tasks:
  - define request/response schema for TCN `8000`
  - define success/failure callback or polling behavior
  - define duplicate/idempotency behavior for repeated `TradeNo`
  - define machine-id mapping rules
- Exit criterion:
  - implementation spec is precise enough to code without guesswork

## Step 4. Implement adapter layer
- Goal: add the TCN-facing Ballbox endpoints over existing Mercado Pago primitives.
- Inputs:
  - concrete contract spec from Step 3
- Tasks:
  - add machine-facing endpoint(s)
  - map TCN requests to Ballbox/Mercado Pago order creation
  - map approved-payment state back into TCN success flow
  - persist any extra machine correlation fields needed
- Exit criterion:
  - local/manual adapter flow works without real machine hardware

## Step 5. Hardware validation when machines arrive
- Goal: verify actual stock TCN machine behavior.
- Tasks:
  - test real QR render on machine screen
  - test payment approval -> vend path
  - capture timing, retries, and failure cases
- Exit criterion:
  - one real vend succeeds through Ballbox-backed payment flow

## Step 6. Only then revisit payout automation
- Goal: avoid solving club payout distribution before the machine path is stable.
- Current default:
  - scheduled manual settlements / bookkeeping
- Related docs:
  - `docs/payments-club-settlement-strategy.md`
  - `PAY-4` in `docs/next-steps-queue.md`

[toolResult]
import { NextRequest } from "next/server";
import { recordApprovedPayment, serializePaymentSession } from "@/lib/payments";
import {
  extractMercadoPagoOrderId,
  fetchMercadoPagoOrder,
  mapMercadoPagoOrderToApprovedPayment,
} from "@/lib/payments-mercadopago";

export async function POST(request: NextRequest) {
  const payload = (await request.json().catch(() => ({}))) as {
    id?: string | number;
    data?: { id?: string | number };
    resource?: string;
    action?: string;
    type?: string;
    live_mode?: boolean;
  };

  const requestId = request.headers.get("x-request-id");
  const orderId = extractMercadoPagoOrderId(payload, request.url);

  if (payload.type === "topic_merchant_order_wh") {
    console.info("[payments][mercadopago-webhook] ignored", {
      orderId,
      requestId,
      reason: "MERCADOPAGO_ORDER_TOPIC_UNSUPPORTED",
      action: payload.action ?? null,
      type: payload.type ?? null,
      liveMode: payload.live_mode ?? null,
    });
    return Response.json(
      {
        ok: true,
        ignored: true,
        reason: "MERCADOPAGO_ORDER_TOPIC_UNSUPPORTED",
      },
      { status: 202 }
    );
  }

  const orderResult = await fetchMercadoPagoOrder(orderId as string);
  if (!orderResult.ok) {
    console.warn("[payments][mercadopago-webhook] order fetch failed", {
      orderId,
      requestId,
      error: orderResult.error,
      status: orderResult.status,
      action: payload.action ?? null,
      type: payload.type ?? null,
      liveMode: payload.live_mode ?? null,
    });
    return Response.json(
      {
        ok: false,
        error: orderResult.error,
      },
      { status: orderResult.status === 404 ? 404 : 502 }
    );
  }

  const mapped = mapMercadoPagoOrderToApprovedPayment(orderResult.order);
  if (!mapped.ok) {
    console.info("[payments][mercadopago-webhook] ignored", {
      orderId,
      requestId,
      reason: mapped.reason,
      action: payload.action ?? null,
      type: payload.type ?? null,
      liveMode: payload.live_mode ?? null,
    });
    return Response.json(
      {
        ok: true,
        ignored: true,
        reason: mapped.reason,
      },
      { status: 202 }
    );
  }

  const saved = await recordApprovedPayment(mapped.value);
  console.info("[payments][mercadopago-webhook] approved persisted", {
    orderId,
    requestId,
    sessionId: saved.id,
    action: payload.action ?? null,
    type: payload.type ?? null,
    liveMode: payload.live_mode ?? null,
  });

  return Response.json({
    ok: true,
    source: "mercadopago-webhook",
    item: serializePaymentSession(saved),
  });
}

[toolResult]
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?: string | number;
  date_created?: string;
  status?: string;
  order_status?: string;
  payments?: Array<{
    id?: string | number;
    status?: string;
    status_detail?: string;
  }>;
  items?: Array<{
    id?: string | number;
    external_code?: 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 parseMercadoPagoAmountToMinorUnits(amount: string | number | undefined) {
  const numericAmount = typeof amount === "string" ? Number(amount.trim()) : amount;
  return Number.isFinite(numericAmount) ? Math.round((numericAmount as number) * 100) : NaN;
}

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 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 firstItem = order.items?.[0];
  const productId =
    firstItem?.id !== undefined && firstItem?.id !== null
      ? String(firstItem.id)
      : firstItem?.external_code !== undefined && firstItem?.external_code !== null
        ? String(firstItem.external_code)
        : "";
  if (!productId) {
    return {
      ok: false,
      reason: "MERCADOPAGO_ORDER_PRODUCT_ID_MISSING",
    };
  }

  const amountMinor = parseMercadoPagoAmountToMinorUnits(order.total_amount);
  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,
      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;
}) {
  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" }],
        },
      ],
    }),
  });

  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,
    },
  };
}

[toolResult]
const anyAmountMinor = useMemo(() => parseMoneyInputToMinor(anyAmountForm.amountMinor), [anyAmountForm.amountMinor]);
  const anyAmountValidationError = useMemo(() => {
    if (!anyAmountForm.vendingMachineId) return "Select a vending machine.";
    if (!anyAmountForm.productId) return "Select a product.";
    if (anyAmountMinor === null) return "Use a value like 15.00.";
    if (anyAmountMinor < MIN_PAYMENT_AMOUNT_MINOR) return "Minimum amount is 15.00 ARS.";
    return null;
  }, [anyAmountForm.productId, anyAmountForm.vendingMachineId, anyAmountMinor]);

  const productAmountMinor = useMemo(() => {
    if (!productAmountForm.amountMinor.trim()) return selectedProduct?.priceMinor ?? null;
    return parseMoneyInputToMinor(productAmountForm.amountMinor);
  }, [productAmountForm.amountMinor, selectedProduct?.priceMinor]);

  const productAmountValidationError = useMemo(() => {
    if (!productAmountForm.vendingMachineId) return "Select a vending machine.";
    if (!productAmountForm.productId) return "Select a product.";
    if (!productAmountForm.amountMinor.trim() && selectedProduct?.priceMinor === null) return "This product has no default price.";
    if (productAmountForm.amountMinor.trim() && productAmountMinor === null) return "Use a value like 1000.00.";
    if (productAmountMinor !== null && productAmountMinor < MIN_PAYMENT_AMOUNT_MINOR) return "Minimum amount is 15.00 ARS.";
    return null;
  }, [productAmountForm.amountMinor, productAmountForm.productId, productAmountForm.vendingMachineId, productAmountMinor, selectedProduct?.priceMinor]);

  async function reloadData() {
    const response = await fetch("/api/admin/payments/operations", { cache: "no-store" });
    if (!response.ok) {
      throw new Error(await readError(response));
    }

    const payload = (await response.json()) as { item: PaymentsAdminData };
    startTransition(() => {
      setData(payload.item);
    });
  }

  async function runMutation(task: () => Promise<void>, successMessage: string) {
    setBusy(true);
    setMessage(null);

    try {
      await task();
      await reloadData();
      setMessage(successMessage);
    } catch (error) {
      setMessage(normalizeError(error));
    } finally {
      setBusy(false);
    }
  }

  function updateItem(sessionId: string, updater: (item: SerializedPaymentSession) => SerializedPaymentSession) {
    setData((current) => ({
      ...current,
      items: current.items.map((item) => {
        if (item.sessionId !== sessionId) return item;

        const next = updater(item);
        const parsedReference = parsePaymentExternalReference(next.externalReference);

        return {
          ...next,
          correlation: {
            parsedReference,
            externalReferenceIsCanonical: parsedReference !== null,
            vendingMachineIdMatchesReference: parsedReference ? parsedReference.vendingMachineId === next.vendingMachineId : false,
            machineTxnIdMatchesReference: parsedReference ? parsedReference.machineTxnId === next.machineTxnId : false,
          },
        };
      }),
    }));
  }

  async function createQrOrder(payload: Record<string, unknown>) {
    const response = await fetch("/api/integrations/payments/orders", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      throw new Error(await readError(response));
    }

    const body = (await response.json()) as {
      item: {
        orderId: string;
        externalReference: string;
        amountMinor: number;
        currency: string;
        mode: string;
        externalPosId: string;
        status: string;
        statusDetail: string;
        expiresAt: string | null;
        qrData: string | null;
      };
    };

    const description = typeof payload.description === "string" ? payload.description : "";
    setGeneratedQr({
      orderId: body.item.orderId,
      externalReference: body.item.externalReference,
      amountMinor: body.item.amountMinor,
      currency: body.item.currency,
      mode: body.item.mode,
      description,
      externalPosId: body.item.externalPosId,
      vendingMachineId: String(payload.vendingMachineId ?? ""),
      productId: String(payload.productId ?? ""),
      qrData: body.item.qrData,
    });
    setMessage(`QR creado: ${body.item.orderId}`);
  }

  return (

Live QR generator

[540 more lines in file. Use offset=280 to continue.]

[toolResult]
import { beforeEach, describe, expect, it, vi } from "vitest";

const findVendingMachine = vi.fn();
const findProduct = vi.fn();
const createMercadoPagoQrOrder = vi.fn();

vi.mock("@/lib/prisma", () => ({
  prisma: {
    vendingMachine: {
      findUnique: findVendingMachine,
    },
    product: {
      findUnique: findProduct,
    },
  },
}));

vi.mock("@/lib/payments-mercadopago", () => ({
  getMercadoPagoConfig: () => ({
    accessToken: "token",
    webhookSecret: "secret",
    externalPosId: "STORE001POS001",
    publicApiBaseUrl: "https://api.mercadopago.com",
  }),
  createMercadoPagoQrOrder,
}));

vi.mock("@/lib/public-url", () => ({
  getPublicAppUrl: () => "https://ballbox.app",
}));

describe("payments order create route", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("creates a mercadopago qr order with canonical reference", async () => {
    findVendingMachine.mockResolvedValue({ id: "vm_001", label: "Central 1" });
    findProduct.mockResolvedValue({ id: "product_tubo_pelotas", name: "Tubo de pelotas" });
    createMercadoPagoQrOrder.mockResolvedValue({
      ok: true,
      value: {
        orderId: "ORD123",
        externalReference: "payment-vm_vm_001-txn_txn_123",
        vendingMachineId: "vm_001",
        productId: "product_tubo_pelotas",
        machineTxnId: "txn_123",
        amountMinor: 1500,
        currency: "ARS",
        mode: "dynamic",
        externalPosId: "STORE001POS001",
        status: "created",
        statusDetail: "created",
        expiresAt: null,
        qrData: "000201...",
      },
    });

    const { POST } = await import("@/app/api/integrations/payments/orders/route");
    const response = await POST(
      new Request("http://localhost/api/integrations/payments/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          vendingMachineId: "vm_001",
          productId: "product_tubo_pelotas",
          amountMinor: 1500,
        }),
      })
    );
    const payload = await response.json();

    expect(response.status).toBe(201);
    expect(payload.ok).toBe(true);
    expect(createMercadoPagoQrOrder).toHaveBeenCalledWith(
      expect.objectContaining({
        vendingMachineId: "vm_001",
        productId: "product_tubo_pelotas",
        amountMinor: 1500,
        externalPosId: "STORE001POS001",
        currency: "ARS",
        mode: "dynamic",
        description: "Tubo de pelotas (Central 1)",
        expirationTime: "PT15M",
      })
    );

    const call = createMercadoPagoQrOrder.mock.calls[0]?.[0] as { externalReference: string };
    expect(call.externalReference.startsWith("payment-vm_vm_001-txn_txn_")).toBe(true);
  });

  it("uses the product price when amount is omitted", async () => {
    findVendingMachine.mockResolvedValue({ id: "vm_001", label: "Central 1" });
    findProduct.mockResolvedValue({ id: "product_tubo_pelotas", name: "Tubo de pelotas" });
    createMercadoPagoQrOrder.mockResolvedValue({
      ok: true,
      value: {
        orderId: "ORD124",
        externalReference: "payment-vm_vm_001-txn_txn_124",
        vendingMachineId: "vm_001",
        productId: "product_tubo_pelotas",
        machineTxnId: "txn_124",
        amountMinor: 100000,
        currency: "ARS",
        mode: "dynamic",
        externalPosId: "STORE001POS001",
        status: "created",
        statusDetail: "created",
        expiresAt: null,
        qrData: "000201...",
      },
    });

    const { POST } = await import("@/app/api/integrations/payments/orders/route");
    const response = await POST(
      new Request("http://localhost/api/integrations/payments/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          vendingMachineId: "vm_001",
          productId: "product_tubo_pelotas",
        }),
      })
    );

    expect(response.status).toBe(201);
    expect(createMercadoPagoQrOrder).toHaveBeenCalledWith(
      expect.objectContaining({
        vendingMachineId: "vm_001",
        productId: "product_tubo_pelotas",
        amountMinor: 100000,
        externalPosId: "STORE001POS001",
        currency: "ARS",
        mode: "dynamic",
        description: "Tubo de pelotas (Central 1)",
        expirationTime: "PT15M",
      })
    );
  });
});

[Assistant]
Implementable now, local-only:

1. Owner/config resolution layer
- Add `PaymentOwnerMode` + resolved MP config abstraction.
- Refactor `lib/payments-mercadopago.ts` helpers to accept explicit config, not global env.
- Why now: pure internal plumbing. No machine needed.
- Files: new `lib/payments-mercadopago-config.ts`, edit `lib/payments-mercadopago.ts`, webhook/order callers.

2. Machine payment service layer
- Add `lib/payments-machine-flow.ts`.
- Move `/api/integrations/payments/orders` onto shared service.
- Make admin QR flow a harness over same machine-payment path.
- Why now: existing order route already does most of this, just route extraction + normalization.

3. DB ownership fields on `VendingMachine`
- Add:
  - `paymentOwnerMode`
  - `paymentOwnerKey`
  - optional `paymentExternalPosId`
- Why now: needed for deterministic owner resolution. No external dependency.
- Cost: Prisma migration + seed/test updates.

4. `PaymentOperation` pre-approval table
- Persist initiated machine payment state:
  - machine txn
  - external ref
  - provider order id
  - owner mode/key
  - status
  - amount
- Why now: needed for webhook correlation, duplicate handling, later vend handshake.
- Cost: migration + backend functions + tests.

5. Webhook correlation upgrade
- Webhook resolves config by `providerOrderId -> PaymentOperation` first.
- Fallback: parse external ref -> machine -> owner resolution.
- Why now: exact TCN handshake unknown, but approval persistence path can still be hardened now.

6. Duplicate/idempotency rules
- Reuse/reject repeated machine `TradeNo` per machine.
- Add unique/index rules around `externalReference` and provider order mapping.
- Why now: repo-local behavior, independent of hardware.

7. Tests for foundation
- Unit:
  - owner resolution
  - config resolution
  - duplicate `TradeNo`
  - webhook fallback path
- Contract:
  - `/api/integrations/payments/orders` still canonical
  - webhook replay idempotent
- Integration:
  - `BALLBOX` and `TENANT` flows through operation -> webhook -> session

Not implementable yet / blocked:

1. Real TCN adapter contract
- Blocked by unknown `CodeUrl` exact payload.
- Source: `docs/tcn-payments-open-questions.md:4`

2. Payment-success / vend handshake
- Blocked by unknown notify/result shape and whether Ballbox must call `NotityUrl`, poll, or use another endpoint.
- Source: `docs/tcn-payments-open-questions.md:12`

3. Final TCN route schema and failure semantics
- Blocked by unknown retry behavior, timeout expectations, and required extra endpoints.
- Source: `docs/tcn-payments-open-questions.md:25`

4. Hardware validation
- Blocked by no physical machine.
- Needed for QR render and approve->dispense proof.

Recommended first slice:
- Start with slices 1+2+3+4 together as one coherent foundation:
  - owner/config resolver
  - machine-payment service
  - `VendingMachine` ownership fields
  - `PaymentOperation` table
- Reason: unlocks both owner modes, de-risks webhook correlation, and prepares TCN route later without guessing external protocol.

Short verdict:
- Can implement most PAY-3 backend foundation now.
- Cannot safely implement final TCN-facing contract or dispense handshake now.

Warning: No models match pattern "google-gemini-cli/gemini-3-flash-preview"
Warning: No models match pattern "nvidia/nvidia/nemotron-4-340b-instruct"
Warning: No models match pattern "openrouter/nvidia/nemotron-3-super-120b-a12b:free"
Warning: No models match pattern "openrouter/qwen/qwen3-coder:free"

[User] Task: Inspect PLAN.md and docs/next-steps-queue.md. User wants to skip OPS-2 and machine-dependent work. Determine what useful Ballbox work remains that can run now from this machine, even if not top priority. Output ranked list and why.

[Assistant]
**Inspecting Files**

I need to take a closer look at some files. I'm considering whether I should check the docs index or if that's not really necessary. It might be more straightforward to just use the read function on the files instead. Hm, I want to make sure I'm being efficient, so figuring out what’s truly needed will help me tackle this task more effectively. Let's see what the files contain!

{"id":"rs_09a9e6eab1d987d1016a0477e524248193be8a39045b907a58","type":"reasoning","encrypted_content":"gAAAAABqBHfnwLshv1JVQC1JqU0PDZ0ErMyZodJcm6rG-gXYLNT5DcTTIlCK_LQUX1M2_xEikLirB-ZjbPvGregRjv9CUyEXGfUvFNGQVtIFWIVyhdPow59uPyVyQD_r58ioRBYsX-N_09eYDYFNVvOUSKkwlY9Yrpqy40KPA0BlQ2Ns2gzVgHsUX-biij6fhN_qir4KOTzpXOyD4IP2o9I8xEddMPrDlmEG_nV5sNHPUISXO9LeR-E-ipz5ukBLP_fleRELIcdGra9hL5wvQO7V_i42ZS6NcV27rlhXJM-4UOm0-13s1Ck-YiRMEVKw9bzM3xg7_KFzar6eF1QJKFRppzsoCm9C79cRDmPkRh4xEu4exe81J9e4sxNjxv0ExZiaiKVw9r_R-SitRctDNaEkebO64S_n5XLglsJ3KKMa21OyT-Jx51ZYdrVMidfsK7suRW8BG_xQ-SBI72gLb-1JLrkanHXNcK60S9-W_HnPXhlMTJBPlUxubW6rwnbmyRsOaaDcE9ZC7VPYnmu1ViOlo2cTtLqHkUvViAZFVtynwGCBAzAGvBIu4d6ximj3exUVSFhddkwByzlyByu9xitmC3BIvlFSg2AADeca4wsOMORBmqqYGYHPRV9lXejNG3fXbi1IuTgQzX33DRpSm36KjWveJyqR3CH3XBdlBkQkdD2zIanBHgvUAnOT6THUaSPgO0cr7zBMoqjVTEtQWDbuDHrJ4hJQFmt34XrXRYK-Luv9S4Ww9PcxLHGGiCVQ7Xfn4FIal9PzHYAUb48HiEHkB_GKquMEHrgk80d1wIVbga7gIRr9aRgbNSGPVZ2QdiFg_4EZvycBYKvsUmbQTRLdAhgdHdiMXVxkTs6FzEc3-0H_ZQoKqhN-QA7UocQvLsxncVLMjsne9cguadj0znNWHozXcfgkUxEv8g1tNEyRBbMr4C-6BU16bpUFI-IBZ1yRcLg1rC1His1aPuZl_RqbUPIcmPVTMnozOa5FVfPuToN5ZpdJIeg=","summary":[{"type":"summary_text","text":"**Inspecting Files**\n\nI need to take a closer look at some files. I'm considering whether I should check the docs index or if that's not really necessary. It might be more straightforward to just use the read function on the files instead. Hm, I want to make sure I'm being efficient, so figuring out what’s truly needed will help me tackle this task more effectively. Let's see what the files contain!"}]}

[toolResult]
# 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.
- 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.
- That admin QR surface is now positioned as a provider test/debug harness; production vending-machine payments should integrate through the TCN machine protocol and let the machine render the buyer-facing QR.
- Club revenue share is currently handled with scheduled manual settlements/bookkeeping; Mercado Pago native split stays deferred until the machine flow and club count justify it.
- 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.
- `/classes` SSR no longer waits on ATC sportclub-summary hydration just to print club names, and `/classes-by-date` now parallelizes club/date ATC availability lookups behind a short shared server cache so first-byte time stays materially lower under production-like loads.
- 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 workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`: calendar shows the current week in an hour-by-hour matrix centered on the coach's real student classes, grouping simultaneous reservations into one class card, surfacing participant basics (name, sexo, edad, categoria), and marking paid vs pending reservations, and now starts with a large `Agregar clase` CTA that jumps into `Alumnos`; `Performance` shows per-club performance plus weekly earnings and student consistency/review rows, `Alumnos` keeps roster + taken-hour management with the student table visible by default and `Agregar clase` opening a wide inline composer instead of a right-side summary panel, and `Horarios y Clases` now auto-saves club selection, availability, packages, and Mercado Pago setup without explicit save buttons while no longer exposing the club's court-fee pricing to the coach; pending autosave writes are now also flushed if the user leaves the page before the debounce finishes.
- `/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.
- `/clubs/coaches` now lands on a club workspace with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: calendar now works one sede at a time, switches between `Disponibilidad` and `Reservas`, uses clickable tag filters for sede/day/club-owned coaches instead of dropdowns, assigns a persistent color to each coach-enabled court, and shows the selected day with all coach-enabled courts as columns so clubs can see coverage or reservation gaps per court without selecting a court filter; coaches assigned with `Asignar todas las canchas` are treated as flexible coverage and occupy one free court per half-hour, not every court at once; manual class loading and pending requests open from dedicated action buttons above the calendar, and manual class creation now requires selecting an active coach package so clubs cannot create classes for coaches without packages; analytics shows professor/reservation money splits and review placeholders, `Sedes` now starts from a large `Agregar sede` CTA, keeps venue creation behind that action, and lists every venue in an editable table with a deep edit action, and `Profesores` groups invite links plus the club coach table.
- The `/clubs/coaches` calendar cells now avoid repeating redundant court-color bars or per-slot coverage counts inside each block; court color remains visible in the column header and the empty/cubierto cell state carries the coverage signal.
- Club workspace clickable actions are now visually distinguished with stronger accent borders, CTA chips, and hover/focus states, while venue detail edits auto-save in place instead of requiring explicit `Guardar` buttons or reloading the user back to the top, and pending autosave writes now also flush on navigation/unmount so venue/price/schedule edits are not lost when switching screens quickly.
- Club roster reads now tolerate production databases that have not yet received the newest coach/student schema additions: missing `Coach.assignedCourtId`, `CoachStudentBooking`, player contact columns, or coach package tables degrade to empty/null data instead of turning `/api/clubs/coaches` into a 500.
- `/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.
- `/api/coaches/students` now also supports weekly recurring student bookings, and the coach workspace `Alumnos` section can create new students directly before assigning them to one-off or recurring classes.
- `/api/clubs/players` now lets a club create its own students and auto-membership them into that club before assigning them to a professor from the club workspace manual class flow.
- External-coach availability now excludes venue slots already absorbed by club-coach availability, so external coaches only see residual hours after club coaches take priority.
- `/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, including which fixed court each coach covers in that sede; the page is now structured as a visible step-by-step workspace with quick jump cards so coach hours and court selection are no longer hidden inside collapsed panels.
- `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.
- Isolated backend/helper tests for classes, network client helpers, players client helpers, sports backend modules, and TVs runtime invariants now live under `tests/unit`, while route/schema surfaces stay under `tests/contracts`.

## 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 workspace with `Calendar` / `Performance` / `Alumnos` / `Horarios y Clases`
- `/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.
- For the current Ballbox Mercado Pago account/API surface, QR order creation now relies on the account-level webhook configuration instead of sending inline `notification_url`, because Mercado Pago rejects that field on `POST /v1/orders` for this setup.
- 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 coach workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`, with the calendar rendered as a weekly class matrix for the coach's students and reservation payment status, `Performance` dedicated to club-level results and weekly analytics, and `Horarios y Clases` handling external clubs, weekly availability inside allowed venue/club windows, reusable class packages, and a Mercado Pago placeholder handle with per-section auto-save, but without showing venue fee/court-price internals to the coach.
- `/clubs/venues/[venueId]` now also supports recurring hourly price overrides on top of the venue base reservation price, and `/clubs/coaches` calendar cells now surface the effective slot price so clubs can see both coverage/reservations and what that half-hour costs.
- `/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 venue management now persists an optional assigned court per coach inside the venue: a specific court pins coverage to that court, while the default `Asignar todas las canchas` mode lets that coach float across enabled courts and be placed into one free court per half-hour in the club calendar.
- 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.
- Classes route/page callers now also import `lib/classes-backend.ts` directly for offer/request reads and writes, so class surfaces no longer depend on `lib/ballbox-store.ts` as their runtime entrypoint.
- 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.
- TVs admin/runtime route and page callers now import direct TVs backend modules plus shared TV types from `lib/tvs-backend-types.ts`, so `lib/ballbox-store.ts` is now a compatibility facade instead of the primary TVs entrypoint.
- 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-2` TVs runtime parity closure.
- Immediately after `OPS-2`: continue with `P1` dependency order.
- Then continue with `P1` in dependency order: `ARCH-2` and `ARCH-3` -> `PERF-1` -> `PAY-3`.
- Club revenue share is currently handled with scheduled manual settlements; `PAY-4` stays behind the TCN machine-facing adapter work as a future Mercado Pago split option.
- Keep detailed backlog/status only in `docs/next-steps-queue.md` to avoid drift.

## Ordered Payments Track
1. Provider-level Mercado Pago proof without TCN hardware is now closed.
   - result: Ballbox created a real production order, received the public webhook at `/notification/mercadopago`, fetched the approved order, and persisted the payment with visible reconciliation evidence in `/admin/payments`
   - notable fixes during closure: Mercado Pago order mapping now accepts `items[0].external_code` as product id fallback and parses `total_amount` from either string or number
   - docs: `docs/implemented-payments.md`, `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
2. After `OPS-1`, treat provider risk as materially reduced but not machine risk.
   - still required for machine confidence: real TCN `8000` request validation, `CodeUrl` rendering confirmation, approval-to-dispense handshake proof, duplicate/retry handling, timeout/expiry handling, and an operator runbook for approved-but-no-vend incidents
3. Ask TCN sales to confirm the remaining machine-contract questions with their engineers.
   - docs: `docs/tcn-payments-open-questions.md`, `docs/tcn-payments-integration-plan.md`
   - note: this is required because the machines are not here yet
4. Freeze the Ballbox <-> TCN adapter contract once those answers come back.
   - docs: `docs/tcn-payments-integration-plan.md`, `docs/tcn-payments-execution-plan.md`
5. Implement one machine payment flow with two owner modes.
   - `BALLBOX`: Ballbox-collected club machines
   - `TENANT`: tenant/client-collected machines such as Adidas rentals
   - backlog: `PAY-3` in `docs/next-steps-queue.md`
   - doc: `docs/payments-machine-rollout-rfc.md`
6. Use guided onboarding for the first wave of 5 machines.
   - first wave target: 3 `BALLBOX` machines + 2 `TENANT` machines
   - docs: `docs/payments-machine-rollout-rfc.md`, `docs/tcn-payments-execution-plan.md`
7. Keep club settlements informal/manual for Ballbox-collected machines until the machine path is stable and settlement pain is real.
   - backlog: `PAY-4` in `docs/next-steps-queue.md`
   - doc: `docs/payments-club-settlement-strategy.md`
8. Revisit automation later only if volume or ops pain justify it.
   - current future candidate: Mercado Pago split / marketplace

### Payments Track Docs
- Master rollout RFC: `docs/payments-machine-rollout-rfc.md`
- Technical implementation RFC: `docs/payments-machine-implementation-rfc.md`
- Implemented provider layer: `docs/implemented-payments.md`
- TCN direction and target flow: `docs/tcn-payments-integration-plan.md`
- Open external questions: `docs/tcn-payments-open-questions.md`
- Ordered execution sequence: `docs/tcn-payments-execution-plan.md`
- Club settlement strategy: `docs/payments-club-settlement-strategy.md`
- Current Mercado Pago webhook blocker/evidence: `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
- TCN protocol evidence artifact: `test-results/tcn-payment-protocol/findings-2026-04-23.md`

### 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`.
- Production vending-machine payments should target a TCN adapter over that native MercadoPago layer; see `docs/tcn-payments-integration-plan.md`.
- 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`.
- Seed already run successfully against that DB.
- Current DB counts after seed were:
  - clubs: 2
  - venues: 3
  - vendingMachines: 4
  - tvDevices: 2
  - screens: 2
  - advertisers: 2
  - campaigns: 2
  - creatives: 3
  - assignments: 3
  - products: 2
  - players: 3
  - memberships: 4
  - coaches: 2
  - openMatches: 2
  - classes: 2
- New DB-backed read/write layer lives in `lib/ballbox-store.ts`.
- Bridge-era in-memory TVs modules were removed; seed fixtures now live under `prisma/seed-data.ts`.
- Routes already moved:
  - `/api/network`
  - `/api/network/[clubId]`
  - `/api/tvs/heartbeat`
  - `/api/tvs/impression`
  - `/api/tvs/manifest/[screenId]` via `lib/tvs-source.ts`
  - `/api/tvs/screens` and `/api/tvs/health` via `lib/tvs-source.ts`
- TVs persistence cleanup is complete; do not reintroduce in-memory compatibility layers.

[toolResult]
# 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: completed
- Depends on: none
- Scope shipped:
  - public webhook smoke against the native MercadoPago webhook path
  - real payment persisted as approved in `/admin/payments`
  - observed payload quirks identified and handled
- 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 order fetch verification, 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.
- Progress (2026-04-23): TCN protocol review shows the `additional hardware devices` note is about machine-side QR scanning, while the machine payment flow (`FunCode=8000`) already returns `CodeUrl` for machine-screen QR display. This means the admin QR page should be treated as a provider test harness, and production integration should move to a TCN adapter layer.
- Progress (2026-05-05): first production order-create attempt against `https://ballbox.app/api/integrations/payments/orders` failed before buyer payment with Mercado Pago `400 unsupported_properties` because `notification_url` was rejected (`additionalProperties '$.notification_url' not allowed`). Ballbox should stop sending inline `notification_url` for this account/API surface and rely on the existing account-level Mercado Pago webhook configuration before rerunning the real-payment check.
- Closure (2026-05-13): a real approved payment now persists locally and is visible in `/admin/payments`. Mercado Pago webhook noise from `topic_merchant_order_wh` remains intentionally ignored. Closure required two Ballbox compatibility fixes for live provider payloads: product id fallback from `items[0].external_code` and robust parsing of `total_amount` when Mercado Pago returns a string.
- 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: completed
- Depends on: ARCH-1 (done)
- Scope shipped:
  - TVs admin/runtime pages and routes now import direct TVs backend modules instead of `lib/ballbox-store.ts`
  - classes pages/routes/helpers now import `lib/classes-backend.ts` directly for offer/request reads and writes
  - shared TV types now live in `lib/tvs-backend-types.ts`, keeping `lib/ballbox-store.ts` as a compatibility facade instead of the primary entrypoint
- Evidence:
  - `app/admin/tvs/page.tsx`
  - `app/api/admin/tvs/*`
  - `app/api/tvs/heartbeat/route.ts`
  - `app/api/classes/requests/route.ts`
  - `lib/classes.ts`
  - `lib/tvs-source.ts`
- Expected payoff: less coupling and better module discoverability

### ARCH-3 Test taxonomy split
- Priority: P1
- Status: completed
- Depends on: DOC-1 (done)
- Scope shipped:
  - isolated backend/helper tests moved out of `tests/contracts` into `tests/unit`
  - route/schema contract coverage stayed in `tests/contracts`
  - `vitest` layered scripts/includes remain aligned with `contracts`, `unit`, and `integration`
- Evidence:
  - `tests/unit/classes/backend.test.ts`
  - `tests/unit/network/backend-mappers.test.ts`
  - `tests/unit/network/use-network-admin-client-state.test.tsx`
  - `tests/unit/players/use-players-admin-client-state.test.tsx`
  - `tests/unit/sports/backend-events.test.ts`
  - `tests/unit/tvs/runtime.test.ts`
- Expected payoff: faster debugging and more intentional coverage growth

### PERF-1 TTFB focused pass
- Priority: P1
- Status: completed
- Depends on: OPS-1
- Scope shipped:
  - measured production-like local server timings for `/admin`, `/classes`, and `/classes-by-date`
  - removed ATC summary hydration from `/classes` SSR club-name shaping
  - parallelized `/classes-by-date` ATC club/day lookups and added a short shared cache for the assembled calendar
- Evidence:
  - `lib/classes-backend.ts`
  - `lib/coach-availability-matching.ts`
  - local timings moved from roughly `/classes` 3.09s and `/classes-by-date` 6.54s to `/classes` 1.96s and `/classes-by-date` 2.69s on the same Pi
- 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 TCN vending-machine adapter over native MercadoPago
- Priority: P1
- Status: ready
- Depends on: OPS-1, PAY-2
- Scope:
  - define the Ballbox machine-facing adapter for TCN payment init (`FunCode=8000`) over the existing MercadoPago order-create route
  - map TCN `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`, and response `CodeUrl` to Ballbox-native payment fields
  - define the payment-success handshake back to TCN so approved payments can unlock dispense
  - support one shared machine payment flow with two payment-owner modes:
    - `BALLBOX` for club machines that Ballbox collects on
    - `TENANT` for customer-owned collection setups such as Adidas rental machines
  - keep `/admin/payments` positioned as an operator test/debug harness rather than the production vending UX
  - document the exact adapter contract and remaining TCN unknowns before implementation
- Notes:
  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
  - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
  - do not assume extra scanner hardware for the buyer flow; the open question is adapter contract detail, not QR display feasibility
  - club settlement automation can be revisited after the machine-facing flow is stable; manual bookkeeping is the current default
- Expected payoff: closes the actual machine integration path without fragmenting Ballbox collection vs tenant-direct collection into separate technical stacks

## P2 Sequenced Refactors

### PAY-4 Mercado Pago native split for club revenue share
- Priority: P2
- Status: blocked
- Depends on: OPS-1, PAY-2, PAY-3
- Scope:
  - keep scheduled manual settlements as the current operating default for Ballbox-collected club machines
  - document Mercado Pago native split as the only future automation path under consideration for club payouts
  - if automation is ever chosen, define onboarding, credential storage, rotation, and support rules before implementation
  - capture the trigger conditions that justify leaving manual bookkeeping behind
- Notes:
  - Ballbox currently runs QR vending payments through the native Mercado Pago order/webhook flow, so split automation is a separate future decision
  - first-wave rollout uses mixed owner modes (`BALLBOX` + `TENANT`), and settlement work only applies to the `BALLBOX` side
  - keep this behind real-world payments validation first (`OPS-1`) and machine-facing TCN adapter definition (`PAY-3`)
- Expected payoff: a clear path from manual settlements to a future Mercado Pago split if the machine flow and business volume justify it

### 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

[Assistant]
Ranked useful work left, skipping `OPS-2` and machine-dependent tracks:

1. `ARCH-5` Route IA normalization
   Why:
   - Not machine-dependent.
   - Dependency `DOC-1` is done.
   - Queue says `blocked`, but by its own dependency it is effectively unblocked.
   - Good leverage: reduces routing confusion across `/classes*` and `/coaches*`, which touches active product surfaces.

2. `ARCH-4` Domain folder architecture
   Why:
   - Not machine-dependent.
   - Dependencies `ARCH-2` and `ARCH-3` are done.
   - Queue says `blocked`, but dependency chain says it is effectively ready.
   - Good maintenance payoff: repo readability, ownership boundaries, easier next feature work.

3. `ARCH-6` Naming conventions and metadata cleanup
   Why:
   - Not machine-dependent.
   - Still truly blocked on `ARCH-4`.
   - Worth keeping next after `ARCH-4` because naming drift is already a theme in `PLAN.md`.

4. Sports/operator follow-up for class requests
   Why:
   - Explicitly called out in `PLAN.md` under product continuation constraints:
     “Next sports expansion after `/classes/me`: lightweight operator review for incoming requests.”
   - Not in queue yet, but valid useful product work now.
   - Helps the live `/classes` loop without claiming more discovery certainty than exists.

5. Docs/backlog hygiene pass
   Why:
   - Queue status is stale: `ARCH-4` and `ARCH-5` are marked `blocked` even though deps are complete.
   - `PLAN.md` and queue now contradict each other on what is actually available.
   - Cheap cleanup, improves agent selection and reduces drift.

Not recommended now:
- `PAY-3`: marked ready, but it is machine-facing TCN adapter work. User asked to skip machine-dependent work.
- `PAY-4`: blocked on `PAY-3`.
- `OPS-2`: explicitly skipped.

Best next pick:
- `ARCH-5` if goal is product-facing clarity.
- `ARCH-4` if goal is engineering leverage.

[User] Task: Inspect TCN payment docs and Ballbox payment code. Find contract/spec artifacts that can be advanced now without machine hardware. Output: exact artifacts to write/update and likely code seams.

[Assistant]
**Inspecting documentation**

I need to look into the repository documentation and consider whether to check skill details for the company context. It's likely helpful, but I'm not entirely sure if it's necessary right now. I should search my memory first, but maybe I won't need that. I think it’s best to dive into the documentation directly using bash to read through the files. This should help clarify what I'm looking for!

{"id":"rs_01531f35460a6ac0016a0477e508b4819680e588b59e5b8636","type":"reasoning","encrypted_content":"gAAAAABqBHfoM9yjVsDptH_0wHBmDbw-BLwsbLtzjfPaqNaBepJgT8LF32j6MM9bOS0dEfNjml_o12tip_2iL70SqNDrwKCuqLiLGFCem_jez48ti7qJqeZb5fP2OL2_-SQAChXRPq0JCkqgnjsS0o_dWC0pgZ2zlpULT_urea7QQxkXIY547oIa0nJo-8o2z0ojH8UEsx09tEttNHKJc6UXJl7q8GckbbwCJ16hfoVGI8h3Iau8qW7Lix1MzfFkSyhnlXt8CwHa8qh8bQaBi7pE7PtSRG8Es9jmBa5jITH6UUBx9K-WEkLtLYKnanYwovPOM6K0EzP79kiHZsnqs7S_4DKjnQBhS-STmUmNMfrOeQkPqsfhuHS7xRQSi5ZTml6WFXcuLzxWRGU8CgcFzargpJXHMZeRjC1JUx-tk6Qz-akD3ivh9YTumnd8o2SlZ0AyIQvNdi20bCG4vb3V5fPUxTvAWcoK8clcYPtYPWSdtmnri9ykc1rVHjyzCu8f8bdFn7UijpUdjwKW7vvhr_mY00ykt0RF-mcrhmFj59077MXuzN7W7KvIv8usW2II8__lkhqPP6IkSYt_3BqbRVwE14OeWWq6PQphv6EDafUgrgE8S7JjTm2BypLDc3Q0ck8aPqteKhEd5koPp4_PeA1VjZ49A-TZMOWHOFKyKw7Ckd9trnEJsQAeJkZF0Bd3oWaO4Neme3Q5BmnQgoCkjzn3_OFQ0t899t2I1cvi6EzG_GzZg100Ni7wKsd_LSgwddk8YMh4c1a_ZggLQswTjXrvUb1i0ykRBkrz868eq61Sb0jy7bNs-7yUuOy8yRfBzicPM7iLCsvF_nhy_ZclHZxhdcLUnX65YQdBWVgtWjcdrEvW8nBkhmjDwlAQ1Q6DKBVrdcK0MMAvsXTiH9bxPm1BuiSvyr7RuxFCCslZ2Jn4NKad5iIY8WWBeB1mZ6ZJ1ui88xC6GbHNq6grZip2F-Yhx7x5nm4ZwVT3T9uDTINdsFGafuxnpJIJQ1TNypE1lgliFSTMmxptRLSScbb8INibQwzPHqBd87XvJWREvma6eygHj8pwwzJF9ws-BOPM9IVofiY30fxpslAA56LmFryPm0Whup9CeJ2BnHReSCiabFa3ffXhq_i_ExmxE0pHO9rR19ls2Lm78HeMZR-hlZV6h12iaLuHnw==","summary":[{"type":"summary_text","text":"**Inspecting documentation**\n\nI need to look into the repository documentation and consider whether to check skill details for the company context. It's likely helpful, but I'm not entirely sure if it's necessary right now. I should search my memory first, but maybe I won't need that. I think it’s best to dive into the documentation directly using bash to read through the files. This should help clarify what I'm looking for!"}]}

cd /home/sebas/work/projects/ballbox && git status --short && printf '\n---FILES---\n' && rg -n "TCN|payment|payments|checkout|card|terminal|pos|tap|stripe|mercado|pay" docs PLAN.md . --glob '!node_modules' --glob '!.next' --glob '!dist' --glob '!coverage'

[toolResult]
---
name: ballbox-company
description: Load Ballbox company context and operating namespace
---

# ballbox-company

## Purpose

Load the Ballbox company operating context when the request is about Ballbox.

## Workflow

1. Use the Ballbox namespace at `/mnt/rpi/repos/ballbox-company-agent` as the working context root.
2. Read `/mnt/rpi/repos/ballbox-company-agent/AGENTS.md` and follow its rules.
3. Use `/mnt/rpi/repos/ballbox-company-agent/index.md` as the default dashboard entry point.
4. Prefer shared-memory search before assuming facts; avoid invented certainty.
5. If the work implies repo/code changes, delegate to `~/ai-dev-workflow`.

[toolResult]
./docs/coach-availability-rfc.md:45:- Booking writes or payment flow.
./docs/coach-availability-rfc.md:58:- We should allow overlapping ranges; the UI should prevent accidental duplicates when possible.
./docs/coach-availability-rfc.md:151:  - now also stores `passwordHash` and `mercadoPagoHandle`
./docs/coach-availability-rfc.md:206:- Coaches can also save a Mercado Pago alias/email placeholder for future payout wiring.
./docs/coaches-design-partner-outreach.md:26:- whether they seem like a possible design partner vs only a research call
./docs/atc-integration-spec-skeleton.md:7:## Purpose
./tests/contracts/classes/routes.test.ts:86:    const payload = await response.json();
./tests/contracts/classes/routes.test.ts:88:    expect(classesOffersResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/classes/routes.test.ts:98:    const payload = await response.json();
./tests/contracts/classes/routes.test.ts:100:    expect(classesOfferDetailResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/classes/routes.test.ts:110:    const payload = await response.json();
./tests/contracts/classes/routes.test.ts:113:    expect(classesErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/classes/routes.test.ts:148:    const payload = await response.json();
./tests/contracts/classes/routes.test.ts:151:    expect(classRequestCreateResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/classes/routes.test.ts:157:    const payload = await response.json();
./tests/contracts/classes/routes.test.ts:159:    expect(classesSessionStatusResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/classes/routes.test.ts:160:    expect(payload).toEqual({ ok: true, enabled: false, mode: "disabled" });
./docs/coaches-interview-script.md:36:- Would you pay for that yourself, or would the club/academy need to pay?
./docs/coaches-interview-script.md:47:- The buyer is unclear and the user would not pay.
./docs/payments-machine-implementation-rfc.md:4:- Proposed technical implementation slice for `PAY-3`.
./docs/payments-machine-implementation-rfc.md:5:- Builds on `docs/payments-machine-rollout-rfc.md`.
./docs/payments-machine-implementation-rfc.md:8:Implement one machine-payment flow in Ballbox that:
./docs/payments-machine-implementation-rfc.md:9:- serves TCN machines
./docs/payments-machine-implementation-rfc.md:11:- persists approved payments through the Ballbox webhook
./docs/payments-machine-implementation-rfc.md:12:- supports two payment-owner modes without forking the stack:
./docs/payments-machine-implementation-rfc.md:24:- order create route: `app/api/integrations/payments/orders/route.ts`
./docs/payments-machine-implementation-rfc.md:25:- webhook intake: `app/notification/mercadopago/route.ts`
./docs/payments-machine-implementation-rfc.md:26:- provider helper: `lib/payments-mercadopago.ts`
./docs/payments-machine-implementation-rfc.md:27:- payment persistence + correlation: `lib/payments.ts`
./docs/payments-machine-implementation-rfc.md:28:- payment contracts: `lib/contracts/payments.ts`
./docs/payments-machine-implementation-rfc.md:33:- machine-facing TCN adapter does not exist yet
./docs/payments-machine-implementation-rfc.md:35:## Proposed architecture
./docs/payments-machine-implementation-rfc.md:39:- which payment-owner mode applies to this machine/request/order
./docs/payments-machine-implementation-rfc.md:42:Proposed concept:
./docs/payments-machine-implementation-rfc.md:43:- `lib/payments-mercadopago-config.ts`
./docs/payments-machine-implementation-rfc.md:64:## 2. Resolve payment owner per machine
./docs/payments-machine-implementation-rfc.md:68:- add machine-level payment ownership fields to the Ballbox data model
./docs/payments-machine-implementation-rfc.md:72:- `paymentOwnerMode`
./docs/payments-machine-implementation-rfc.md:73:- `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:74:- optional override `paymentExternalPosId`
./docs/payments-machine-implementation-rfc.md:77:- `paymentOwnerMode`: `BALLBOX` or `TENANT`
./docs/payments-machine-implementation-rfc.md:78:- `paymentOwnerKey`: resolver key for the MP config to use
./docs/payments-machine-implementation-rfc.md:79:- `paymentExternalPosId`: optional machine-specific override when not using the owner default POS
./docs/payments-machine-implementation-rfc.md:89:- `payment-vm_{vendingMachineId}-txn_{machineTxnId}`
./docs/payments-machine-implementation-rfc.md:94:Recommended minimum persisted fields on payment/session side:
./docs/payments-machine-implementation-rfc.md:98:- `paymentOwnerMode`
./docs/payments-machine-implementation-rfc.md:99:- `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:101:If `PaymentSession` should remain approved-only and lean, add a lightweight pre-approval payment operation record instead.
./docs/payments-machine-implementation-rfc.md:103:## 4. Introduce a pre-approval payment operation record
./docs/payments-machine-implementation-rfc.md:109:- add a lightweight table for initiated machine payments
./docs/payments-machine-implementation-rfc.md:121:- `paymentOwnerMode`
./docs/payments-machine-implementation-rfc.md:122:- `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:149:- `lib/payments-machine-flow.ts`
./docs/payments-machine-implementation-rfc.md:154:- create or reuse payment operation row
./docs/payments-machine-implementation-rfc.md:156:- return Ballbox-normalized result for either admin or TCN routes
./docs/payments-machine-implementation-rfc.md:173:## 7. TCN route should sit on top of the machine-payment service
./docs/payments-machine-implementation-rfc.md:175:- do not embed MP logic directly in TCN route handlers
./docs/payments-machine-implementation-rfc.md:176:- TCN route should translate between TCN contract and Ballbox machine-payment service only
./docs/payments-machine-implementation-rfc.md:178:Proposed route family:
./docs/payments-machine-implementation-rfc.md:179:- `app/api/integrations/tcn/payments/init/route.ts` or similar
./docs/payments-machine-implementation-rfc.md:182:- parse TCN request
./docs/payments-machine-implementation-rfc.md:184:- call machine-payment service
./docs/payments-machine-implementation-rfc.md:185:- return TCN response shape including `CodeUrl`
./docs/payments-machine-implementation-rfc.md:188:Admin route should reuse the same service layer where possible.
./docs/payments-machine-implementation-rfc.md:189:That avoids a separate payment stack for admin testing vs machine runtime.
./docs/payments-machine-implementation-rfc.md:192:- `/api/integrations/payments/orders` becomes a Ballbox test/operator entrypoint
./docs/payments-machine-implementation-rfc.md:193:- TCN adapter becomes the real machine entrypoint
./docs/payments-machine-implementation-rfc.md:194:- both converge on one machine-payment service underneath
./docs/payments-machine-implementation-rfc.md:199:Add machine ownership to `VendingMachine` and payment initiation state to a new `PaymentOperation` table.
./docs/payments-machine-implementation-rfc.md:216:1. webhook payload yields `orderId`
./docs/payments-machine-implementation-rfc.md:218:3. from that row Ballbox gets `paymentOwnerMode` + `paymentOwnerKey`
./docs/payments-machine-implementation-rfc.md:235:- unique key on `providerOrderId` if possible
./docs/payments-machine-implementation-rfc.md:248:Add machine-payment service layer.
./docs/payments-machine-implementation-rfc.md:254:Move `/api/integrations/payments/orders` onto that service.
./docs/payments-machine-implementation-rfc.md:257:Add TCN adapter route using the same service.
./docs/payments-machine-implementation-rfc.md:266:- TCN init route returns expected TCN response shape
./docs/payments-machine-implementation-rfc.md:281:- exact TCN `CodeUrl` payload expectation
./docs/payments-machine-implementation-rfc.md:282:- exact TCN payment-success / dispense handshake
./docs/payments-machine-implementation-rfc.md:289:- a shared machine-payment service
./docs/payments-machine-implementation-rfc.md:295:- `docs/payments-machine-rollout-rfc.md`
./docs/payments-machine-implementation-rfc.md:296:- `docs/tcn-payments-integration-plan.md`
./docs/payments-machine-implementation-rfc.md:297:- `docs/tcn-payments-execution-plan.md`
./docs/payments-machine-implementation-rfc.md:298:- `docs/implemented-payments.md`
./docs/coaches-solution-thesis.md:36:- Not assuming the first win is payments
./docs/coaches-solution-thesis.md:54:  - most aligned with Ballbox's ATC-driven unfair advantage without requiring impossible access depth
./docs/coaches-solution-thesis.md:84:- Fallback commercialization rule: if coaches love the workflow but clubs/coordinators are the realistic payer, Ballbox should pivot GTM and packaging toward clubs/coordinators.
./docs/classes-mvp-rfc.md:4:- Proposed
./docs/classes-mvp-rfc.md:14:- The bigger drain is that coaches coordinate interest, answer messages, propose times, and absorb repetitive 1:N scheduling friction.
./docs/classes-mvp-rfc.md:55:## Proposed Domain Changes
./docs/classes-mvp-rfc.md:120:- full booking checkout
./docs/classes-mvp-rfc.md:135:- let the student choose a specific day/time before submitting, without exposing court choice yet
./tests/contracts/accounts/routes.test.ts:62:    const payload = await response.json();
./tests/contracts/accounts/routes.test.ts:64:    expect(accountSessionResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/accounts/routes.test.ts:85:    const payload = await response.json();
./tests/contracts/accounts/routes.test.ts:87:    expect(accountSessionResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/accounts/routes.test.ts:102:    const payload = await response.json();
./tests/contracts/accounts/routes.test.ts:105:    expect(accountErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/accounts/routes.test.ts:106:    expect(payload.error).toBe("DATABASE_UNAVAILABLE");
./docs/maintainability-priorities-2026-03.md:3:## Purpose
./docs/maintainability-priorities-2026-03.md:15:  - P0.1 fail-closed payments callback auth
./docs/maintainability-priorities-2026-03.md:19:  - P1.6 first route-helper/envelope standardization pass across `network`, `players`, `sports`, and `payments`
./docs/maintainability-priorities-2026-03.md:22:  - P0.3 remaining admin clients: `payments`, `tvs`
./docs/maintainability-priorities-2026-03.md:33:### 1. Fail closed on payments callback auth
./docs/maintainability-priorities-2026-03.md:36:- Risk: `app/api/integrations/payments/approved/route.ts` can accept writes in a misconfigured environment.
./docs/maintainability-priorities-2026-03.md:39:  - `app/api/integrations/payments/approved/route.ts`
./docs/maintainability-priorities-2026-03.md:42:  - It weakens the whole payments integration if env setup drifts.
./docs/maintainability-priorities-2026-03.md:60:  - Split by domain: `network`, `tvs`, `payments`, `players`, `sports`, `classes`.
./docs/maintainability-priorities-2026-03.md:62:    - repository: Prisma reads/writes only
./docs/maintainability-priorities-2026-03.md:80:  - `components/payments-admin-client.tsx`
./docs/maintainability-priorities-2026-03.md:96:  - `network-admin-client` is now a thin shell over config, hooks, shell, styles, and smaller sections/cards.
./docs/maintainability-priorities-2026-03.md:97:  - `players` now has dedicated client config, state hook, shell, and smaller stats/create/list-card seams.
./docs/maintainability-priorities-2026-03.md:100:  - apply the same pattern to `payments-admin-client.tsx`
./docs/maintainability-priorities-2026-03.md:129:  - `components/payments-admin-client.tsx`
./docs/maintainability-priorities-2026-03.md:149:  - Inconsistent candidates: `app/api/admin/tvs/screens/[screenId]/route.ts`, `app/api/admin/tvs/tv-devices/route.ts`, `app/api/admin/payments/sessions/route.ts`
./docs/maintainability-priorities-2026-03.md:158:- Follow-on slices shipped: players, sports, and payments route families now use the same helper pattern for JSON parsing plus strict success/error envelopes, including focused invalid-payload contract coverage.
./docs/maintainability-priorities-2026-03.md:216:  - add Prisma-backed integration tests around repository/service modules
./docs/maintainability-priorities-2026-03.md:241:  - `app/api/integrations/payments/health/route.ts`
./docs/maintainability-priorities-2026-03.md:248:  - log integration auth failures and payload rejects
./docs/maintainability-priorities-2026-03.md:265:### 14. Tighten domain lifecycle modeling in sports and payments
./docs/maintainability-priorities-2026-03.md:269:  - `lib/payments.ts`
./docs/maintainability-priorities-2026-03.md:284:1. Fix payments callback auth and add env validation.
./docs/payments-machine-rollout-rfc.md:4:- Proposed operating plan for the first vending rollout.
./docs/payments-machine-rollout-rfc.md:5:- Use this as the master document for payment ownership modes, TCN integration, onboarding, and rollout sequencing.
./docs/payments-machine-rollout-rfc.md:9:- Support two payment-owner modes in that flow:
./docs/payments-machine-rollout-rfc.md:20:- The immediate risk is not payout automation. The immediate risk is getting the machine payment path working end to end.
./docs/payments-machine-rollout-rfc.md:21:- TCN machine integration and Mercado Pago webhook persistence are still the highest-risk technical pieces.
./docs/payments-machine-rollout-rfc.md:29:- TCN machine payment adapter
./docs/payments-machine-rollout-rfc.md:31:- machine-level payment-owner mode selection
./docs/payments-machine-rollout-rfc.md:52:3. buyer pays by scanning the QR shown by the machine
./docs/payments-machine-rollout-rfc.md:54:5. Ballbox fetches order details, verifies approval, persists payment
./docs/payments-machine-rollout-rfc.md:68:3. buyer pays by scanning the QR shown by the machine
./docs/payments-machine-rollout-rfc.md:70:5. Ballbox fetches order details with the tenant-specific credentials, verifies approval, persists payment
./docs/payments-machine-rollout-rfc.md:71:6. tenant owns funds; Ballbox is not doing revenue-share settlement for those payments
./docs/payments-machine-rollout-rfc.md:80:- unified TCN adapter behavior
./docs/payments-machine-rollout-rfc.md:82:- centralized debugging for payment-approved-but-no-vend cases
./docs/payments-machine-rollout-rfc.md:87:- Ballbox owns the payment orchestration path
./docs/payments-machine-rollout-rfc.md:88:- tenant owns the payment account
./docs/payments-machine-rollout-rfc.md:97:- `paymentOwnerMode = BALLBOX | TENANT`
./docs/payments-machine-rollout-rfc.md:98:- payment config reference for Mercado Pago
./docs/payments-machine-rollout-rfc.md:107:- `/notification/mercadopago`
./docs/payments-machine-rollout-rfc.md:109:But Ballbox must be able to determine which payment config applies when an event arrives.
./docs/payments-machine-rollout-rfc.md:111:- provider order / external reference -> vending machine -> payment owner mode -> MP config
./docs/payments-machine-rollout-rfc.md:113:## TCN integration direction
./docs/payments-machine-rollout-rfc.md:114:- TCN remains the machine-facing protocol.
./docs/payments-machine-rollout-rfc.md:116:- Ballbox should implement the TCN payment adapter over the native Mercado Pago provider layer.
./docs/payments-machine-rollout-rfc.md:120:- `docs/tcn-payments-integration-plan.md`
./docs/payments-machine-rollout-rfc.md:121:- `docs/tcn-payments-open-questions.md`
./docs/payments-machine-rollout-rfc.md:122:- `docs/tcn-payments-execution-plan.md`
./docs/payments-machine-rollout-rfc.md:131:- one real approved payment persisted locally
./docs/payments-machine-rollout-rfc.md:135:- `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
./docs/payments-machine-rollout-rfc.md:137:### Phase 1. TCN contract closure
./docs/payments-machine-rollout-rfc.md:139:- confirm TCN `CodeUrl` and approval/dispense handshake details with sales/engineering
./docs/payments-machine-rollout-rfc.md:146:- `docs/tcn-payments-open-questions.md`
./docs/payments-machine-rollout-rfc.md:148:### Phase 2. Single technical path with two payment-owner modes
./docs/payments-machine-rollout-rfc.md:153:- machine-level payment owner resolution
./docs/payments-machine-rollout-rfc.md:154:- machine-facing TCN adapter over provider layer
./docs/payments-machine-rollout-rfc.md:165:- real payment test per machine/account context
./docs/payments-machine-rollout-rfc.md:188:3. map machine identifier between TCN and Ballbox
./docs/payments-machine-rollout-rfc.md:191:6. run one real payment test
./docs/payments-machine-rollout-rfc.md:208:- run one real payment test with their account
./docs/payments-machine-rollout-rfc.md:213:- For tenant-collected machines, Ballbox is facilitating setup, not owning their payout operations.
./docs/payments-machine-rollout-rfc.md:215:- Clubs mainly care about net outcome and avoiding extra invoicing burden, not about ownership of the payment rails themselves.
./docs/payments-machine-rollout-rfc.md:219:- TCN approval/dispense handshake still needs confirmation
./docs/payments-machine-rollout-rfc.md:231:- do not overbuild settlement tooling before real machine payments work
./docs/payments-machine-rollout-rfc.md:234:- one Ballbox-collected machine completes payment flow end to end
./docs/payments-machine-rollout-rfc.md:235:- one tenant-collected machine completes payment flow end to end
./docs/payments-machine-rollout-rfc.md:236:- Ballbox persists approved payments for both owner modes
./docs/payments-machine-rollout-rfc.md:237:- TCN machine-facing contract is documented and implemented without guessing
./docs/payments-machine-rollout-rfc.md:242:- `docs/implemented-payments.md`
./docs/payments-machine-rollout-rfc.md:243:- `docs/tcn-payments-integration-plan.md`
./docs/payments-machine-rollout-rfc.md:244:- `docs/tcn-payments-open-questions.md`
./docs/payments-machine-rollout-rfc.md:245:- `docs/tcn-payments-execution-plan.md`
./docs/payments-machine-rollout-rfc.md:246:- `docs/payments-club-settlement-strategy.md`
./docs/payments-machine-rollout-rfc.md:247:- `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
./docs/coaches-research-repository.md:1:# Coaches Research Repository
./docs/coaches-research-repository.md:20:- Who would pay for a fix?
./docs/coaches-coordination-gap-analysis.md:3:## Purpose
./docs/coaches-coordination-gap-analysis.md:74:- possibly participant sync if ATC supports the relevant booking participant flow
./docs/coaches-coordination-gap-analysis.md:76:## Proposed Minimal Ballbox Changes
./docs/coaches-coordination-gap-analysis.md:98:4. add an internal ATC boundary instead of embedding ATC payloads directly in route handlers
./docs/coaches-wedges-matrix.md:15:| Collections/admin follow-up | Chasing payments and reminders consumes time | Time savings, maybe cashflow | Low | Medium | Medium | Could be too generic and weakly differentiated |
./docs/coaches-wedges-matrix.md:35:- If a wedge requires broad CRM/payments/attendance/product surface from day one, narrow it or drop it.
./tests/contracts/payments/auth.test.ts:4:describe("payments integration routes", () => {
./tests/contracts/payments/auth.test.ts:12:    const { GET, POST } = await import("@/app/api/integrations/payments/approved/route");
./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:24:    const postResponse = await POST();
./tests/contracts/payments/auth.test.ts:25:    const postPayload = await postResponse.json();
./tests/contracts/payments/auth.test.ts:26:    expect(postResponse.status).toBe(410);
./tests/contracts/payments/auth.test.ts:27:    expect(postPayload).toEqual({
./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:39:    const { GET } = await import("@/app/api/integrations/payments/health/route");
./tests/contracts/payments/auth.test.ts:41:    const payload = await response.json();
./tests/contracts/payments/auth.test.ts:44:    expect(payload).toMatchObject({
./tests/contracts/payments/auth.test.ts:47:      orderCreatePath: "/api/integrations/payments/orders",
./tests/contracts/payments/auth.test.ts:48:      mercadopagoWebhookPath: "/notification/mercadopago",
./tests/contracts/payments/auth.test.ts:49:      mercadopago: {
./tests/contracts/payments/auth.test.ts:55:    expect(payload).not.toHaveProperty("sharedSecretConfigured");
./tests/contracts/payments/auth.test.ts:56:    expect(payload).not.toHaveProperty("callbackUrl");
./docs/next-steps-queue.md:3:## Purpose
./docs/next-steps-queue.md:32:- Expected payoff: next-agent selection is immediate
./docs/next-steps-queue.md:49:  - deep relative helper imports removed from network, players, sports, and payments route handlers
./docs/next-steps-queue.md:70:  - real payment persisted as approved in `/admin/payments`
./docs/next-steps-queue.md:71:  - observed payload quirks identified and handled
./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 order fetch verification, and native order creation now exists at `/api/integrations/payments/orders`.
./docs/next-steps-queue.md:74:- Progress (2026-04-23): TCN protocol review shows the `additional hardware devices` note is about machine-side QR scanning, while the machine payment flow (`FunCode=8000`) already returns `CodeUrl` for machine-screen QR display. This means the admin QR page should be treated as a provider test harness, and production integration should move to a TCN adapter layer.
./docs/next-steps-queue.md:75:- Progress (2026-05-05): first production order-create attempt against `https://ballbox.app/api/integrations/payments/orders` failed before buyer payment with Mercado Pago `400 unsupported_properties` because `notification_url` was rejected (`additionalProperties '$.notification_url' not allowed`). Ballbox should stop sending inline `notification_url` for this account/API surface and rely on the existing account-level Mercado Pago webhook configuration before rerunning the real-payment check.
./docs/next-steps-queue.md:76:- Closure (2026-05-13): a real approved payment now persists locally and is visible in `/admin/payments`. Mercado Pago webhook noise from `topic_merchant_order_wh` remains intentionally ignored. Closure required two Ballbox compatibility fixes for live provider payloads: product id fallback from `items[0].external_code` and robust parsing of `total_amount` when Mercado Pago returns a string.
./docs/next-steps-queue.md:77:- Expected payoff: closes highest-risk integration uncertainty
./docs/next-steps-queue.md:87:- 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`.
./docs/next-steps-queue.md:92:- Expected payoff: de-risks constrained-device production behavior
./docs/next-steps-queue.md:111:- Expected payoff: less coupling and better module discoverability
./docs/next-steps-queue.md:128:- Expected payoff: faster debugging and more intentional coverage growth
./docs/next-steps-queue.md:142:- Expected payoff: better responsiveness without blind cache complexity
./docs/next-steps-queue.md:144:### PAY-2 Full payments migration into Ballbox host app
./docs/next-steps-queue.md:153:  - `/notification/mercadopago`
./docs/next-steps-queue.md:154:  - `/api/integrations/payments/orders`
./docs/next-steps-queue.md:155:  - `app/api/integrations/payments/health/route.ts`
./docs/next-steps-queue.md:156:  - `app/api/integrations/payments/sessions/route.ts`
./docs/next-steps-queue.md:157:  - `docs/implemented-payments.md`
./docs/next-steps-queue.md:158:- Expected payoff: one-repo payments ownership, simpler operations, faster debugging
./docs/next-steps-queue.md:160:### PAY-3 TCN vending-machine adapter over native MercadoPago
./docs/next-steps-queue.md:165:  - define the Ballbox machine-facing adapter for TCN payment init (`FunCode=8000`) over the existing MercadoPago order-create route
./docs/next-steps-queue.md:166:  - map TCN `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`, and response `CodeUrl` to Ballbox-native payment fields
./docs/next-steps-queue.md:167:  - define the payment-success handshake back to TCN so approved payments can unlock dispense
./docs/next-steps-queue.md:168:  - support one shared machine payment flow with two payment-owner modes:
./docs/next-steps-queue.md:171:  - keep `/admin/payments` positioned as an operator test/debug harness rather than the production vending UX
./docs/next-steps-queue.md:172:  - document the exact adapter contract and remaining TCN unknowns before implementation
./docs/next-steps-queue.md:174:  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
./docs/next-steps-queue.md:175:  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
./docs/next-steps-queue.md:176:  - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
./docs/next-steps-queue.md:179:- Expected payoff: closes the actual machine integration path without fragmenting Ballbox collection vs tenant-direct collection into separate technical stacks
./docs/next-steps-queue.md:189:  - document Mercado Pago native split as the only future automation path under consideration for club payouts
./docs/next-steps-queue.md:193:  - Ballbox currently runs QR vending payments through the native Mercado Pago order/webhook flow, so split automation is a separate future decision
./docs/next-steps-queue.md:195:  - keep this behind real-world payments validation first (`OPS-1`) and machine-facing TCN adapter definition (`PAY-3`)
./docs/next-steps-queue.md:196:- Expected payoff: a clear path from manual settlements to a future Mercado Pago split if the machine flow and business volume justify it
./docs/next-steps-queue.md:205:- Expected payoff: major readability improvement for future work
./docs/next-steps-queue.md:214:- Expected payoff: lower confusion in feature placement and routing maintenance
./docs/next-steps-queue.md:223:- Expected payoff: fewer naming mismatches and clearer ownership vocabulary
./tests/contracts/payments/admin-routes.test.ts:4:  paymentsAdminErrorResponseSchema,
./tests/contracts/payments/admin-routes.test.ts:5:} from "@/lib/contracts/payments";
./tests/contracts/payments/admin-routes.test.ts:16:vi.mock("@/lib/payments", () => ({
./tests/contracts/payments/admin-routes.test.ts:30:describe("payments admin routes", () => {
./tests/contracts/payments/admin-routes.test.ts:38:  it("validates payment session create success", async () => {
./tests/contracts/payments/admin-routes.test.ts:43:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
./tests/contracts/payments/admin-routes.test.ts:45:      new Request("http://localhost/api/admin/payments/sessions", {
./tests/contracts/payments/admin-routes.test.ts:53:          externalReference: "payment-vm_vm_001-txn_txn_001",
./tests/contracts/payments/admin-routes.test.ts:62:    const payload = await response.json();
./tests/contracts/payments/admin-routes.test.ts:65:    expect(payload).toEqual({ ok: true, item: { sessionId: "ps_001", amountMinor: 1200 } });
./tests/contracts/payments/admin-routes.test.ts:68:  it("returns strict 400 for invalid payment create payload", async () => {
./tests/contracts/payments/admin-routes.test.ts:69:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
./tests/contracts/payments/admin-routes.test.ts:71:      new Request("http://localhost/api/admin/payments/sessions", {
./tests/contracts/payments/admin-routes.test.ts:77:    const payload = await response.json();
./tests/contracts/payments/admin-routes.test.ts:80:    expect(paymentsAdminErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/payments/admin-routes.test.ts:81:    expect(payload).toMatchObject({ ok: false, error: "INVALID_PAYLOAD", status: 400 });
./tests/contracts/payments/admin-routes.test.ts:84:  it("returns strict 404 for payment create with missing vending machine", async () => {
./tests/contracts/payments/admin-routes.test.ts:90:    const { POST } = await import("@/app/api/admin/payments/sessions/route");
./tests/contracts/payments/admin-routes.test.ts:92:      new Request("http://localhost/api/admin/payments/sessions", {
./tests/contracts/payments/admin-routes.test.ts:100:          externalReference: "payment-vm_vm_missing-txn_txn_001",
./tests/contracts/payments/admin-routes.test.ts:109:    const payload = await response.json();
./tests/contracts/payments/admin-routes.test.ts:112:    expect(payload).toEqual(createPaymentsAdminErrorResponse("VENDING_MACHINE_NOT_FOUND", 404));
./tests/contracts/payments/admin-routes.test.ts:115:  it("returns strict 404 for payment update with missing product", async () => {
./tests/contracts/payments/admin-routes.test.ts:121:    const { PATCH } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
./tests/contracts/payments/admin-routes.test.ts:123:      new Request("http://localhost/api/admin/payments/sessions/ps_001", {
./tests/contracts/payments/admin-routes.test.ts:130:    const payload = await response.json();
./tests/contracts/payments/admin-routes.test.ts:133:    expect(payload).toEqual(createPaymentsAdminErrorResponse("PRODUCT_NOT_FOUND", 404));
./tests/contracts/payments/admin-routes.test.ts:136:  it("returns strict 409 for payment update conflict", async () => {
./tests/contracts/payments/admin-routes.test.ts:141:    const { PATCH } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
./tests/contracts/payments/admin-routes.test.ts:143:      new Request("http://localhost/api/admin/payments/sessions/ps_001", {
./tests/contracts/payments/admin-routes.test.ts:150:    const payload = await response.json();
./tests/contracts/payments/admin-routes.test.ts:153:    expect(payload).toEqual(createPaymentsAdminErrorResponse("PAYMENT_SESSION_CONFLICT", 409));
./tests/contracts/payments/admin-routes.test.ts:156:  it("returns strict 404 for payment delete missing session", async () => {
./tests/contracts/payments/admin-routes.test.ts:159:    const { DELETE } = await import("@/app/api/admin/payments/sessions/[sessionId]/route");
./tests/contracts/payments/admin-routes.test.ts:160:    const response = await DELETE(new Request("http://localhost/api/admin/payments/sessions/ps_missing"), {
./tests/contracts/payments/admin-routes.test.ts:163:    const payload = await response.json();
./tests/contracts/payments/admin-routes.test.ts:166:    expect(payload).toEqual(createPaymentsAdminErrorResponse("PAYMENT_SESSION_NOT_FOUND", 404));
./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:32:describe("payments order create route", () => {
./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:44:        externalReference: "payment-vm_vm_001-txn_txn_123",
./tests/contracts/payments/orders-route.test.ts:59:    const { POST } = await import("@/app/api/integrations/payments/orders/route");
./tests/contracts/payments/orders-route.test.ts:61:      new Request("http://localhost/api/integrations/payments/orders", {
./tests/contracts/payments/orders-route.test.ts:71:    const payload = await response.json();
./tests/contracts/payments/orders-route.test.ts:74:    expect(payload.ok).toBe(true);
./tests/contracts/payments/orders-route.test.ts:89:    expect(call.externalReference.startsWith("payment-vm_vm_001-txn_txn_")).toBe(true);
./tests/contracts/payments/orders-route.test.ts:99:        externalReference: "payment-vm_vm_001-txn_txn_124",
./tests/contracts/payments/orders-route.test.ts:114:    const { POST } = await import("@/app/api/integrations/payments/orders/route");
./tests/contracts/payments/orders-route.test.ts:116:      new Request("http://localhost/api/integrations/payments/orders", {
./tests/contracts/network/routes.test.ts:114:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:116:    expect(networkListResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:126:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:128:    expect(networkClubDetailResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:138:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:141:    expect(networkErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:155:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:158:    expect(networkClubMutationResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:173:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:176:    expect(networkErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:190:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:193:    expect(networkErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:209:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:212:    expect(networkVenueMutationResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:232:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:234:    expect(networkVendingMachineMutationResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:254:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:257:    expect(networkVendingMachineMutationResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:271:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:274:    expect(networkErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:288:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:291:    expect(networkErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/routes.test.ts:301:    const payload = await response.json();
./tests/contracts/network/routes.test.ts:303:    expect(networkDeleteResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/network/network-admin-club-list.test.tsx:11:vi.mock("@/components/network-admin-club-card", () => ({
./tests/contracts/network/network-admin-club-list.test.tsx:14:    return <div data-testid="club-card" />;
./tests/contracts/network/network-admin-club-list.test.tsx:72:    expect(screen.getAllByTestId("club-card")).toHaveLength(2);
./tests/contracts/network/network-admin-create-club-section.test.tsx:34:      cardClassName="section-card"
./tests/contracts/network/network-admin-create-club-section.test.tsx:63:    expect((container.firstChild as HTMLElement | null)?.className).toContain("section-card");
./tests/contracts/network/network-admin-shell.test.tsx:100:        cardClassName: networkAdminShellStyles.statCard,
./tests/contracts/network/network-admin-shell.test.tsx:112:        cardClassName: networkAdminShellStyles.sectionCard,
./tests/contracts/network/network-admin-venue-card.test.tsx:7:import { NetworkAdminVenueCard } from "@/components/network-admin-venue-card";
./tests/contracts/network/network-admin-club-card.test.tsx:6:import { NetworkAdminClubCard } from "@/components/network-admin-club-card";
./tests/contracts/network/backend-modules.test.ts:155:  it("blocks vending machine deletion when payment sessions still exist", async () => {
./tests/contracts/network/backend-modules.test.ts:159:      _count: { paymentSessions: 3 },
./tests/contracts/network/backend-modules.test.ts:168:      paymentSessionCount: 3,
./tests/contracts/players/players-admin-stats.test.tsx:43:    render(<PlayersAdminStats players={players} clubs={clubs} cardClassName="stat-card" />);
./tests/contracts/players/routes.test.ts:30:    const payload = await response.json();
./tests/contracts/players/routes.test.ts:32:    expect(playersResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/players-admin-create-player-section.test.tsx:15:      cardClassName="section-card"
./tests/contracts/players/players-admin-create-player-section.test.tsx:37:    expect((container.firstChild as HTMLElement | null)?.className).toContain("section-card");
./tests/contracts/players/players-admin-player-list.test.tsx:11:vi.mock("@/components/players-admin-player-card", () => ({
./tests/contracts/players/players-admin-player-list.test.tsx:14:    return <div data-testid="player-card" />;
./tests/contracts/players/players-admin-player-list.test.tsx:58:  it("forwards each player into the extracted player card seam", () => {
./tests/contracts/players/players-admin-player-list.test.tsx:62:    expect(screen.getAllByTestId("player-card")).toHaveLength(2);
./tests/contracts/players/players-admin-shell.test.tsx:102:        cardClassName: playersAdminShellStyles.statCard,
./tests/contracts/players/players-admin-shell.test.tsx:112:        cardClassName: playersAdminShellStyles.sectionCard,
./tests/contracts/players/admin-routes.test.ts:49:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:51:    expect(adminPlayersListResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:65:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:68:    expect(adminPlayerMutationResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:71:  it("returns strict 400 for invalid player create payload", async () => {
./tests/contracts/players/admin-routes.test.ts:80:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:83:    expect(adminPlayersErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:84:    expect(payload).toMatchObject({ ok: false, error: "INVALID_PAYLOAD", status: 400 });
./tests/contracts/players/admin-routes.test.ts:99:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:102:    expect(adminPlayersErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:120:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:123:    expect(adminPlayerMutationResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:141:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:144:    expect(adminPlayersErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:154:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:156:    expect(adminPlayerDeleteResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:166:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:169:    expect(adminPlayersErrorResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/admin-routes.test.ts:179:    const payload = await response.json();
./tests/contracts/players/admin-routes.test.ts:181:    expect(adminPlayerDeleteResponseSchema.parse(payload)).toEqual(payload);
./tests/contracts/players/players-admin-player-card.test.tsx:6:import { PlayersAdminPlayerCard } from "@/components/players-admin-player-card";
./app/api/admin/payments/operations/route.ts:1:import { getPaymentsAdminDataFromDb } from "@/lib/payments";
./app/api/admin/payments/operations/route.ts:2:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/admin/payments/operations/route.ts:5:  return paymentsJsonResponse({
./app/api/coaches/availability/route.ts:70:      errors.push({ index, message: "El horario de fin debe ser posterior al inicio" });
./app/api/admin/payments/sessions/route.ts:7:  paymentSessionCreateSchema,
./app/api/admin/payments/sessions/route.ts:8:} from "@/lib/contracts/payments";
./app/api/admin/payments/sessions/route.ts:9:import { createPaymentSessionInDb, listPaymentSessionsFromDb, serializePaymentSession } from "@/lib/payments";
./app/api/admin/payments/sessions/route.ts:12:  paymentsErrorResponse,
./app/api/admin/payments/sessions/route.ts:13:  paymentsForeignKeyErrorResponse,
./app/api/admin/payments/sessions/route.ts:14:  paymentsInvalidPayloadResponse,
./app/api/admin/payments/sessions/route.ts:15:  paymentsJsonResponse,
./app/api/admin/payments/sessions/route.ts:16:} from "@/lib/http/payments-route-helpers";
./app/api/admin/payments/sessions/route.ts:21:  return paymentsJsonResponse({
./app/api/admin/payments/sessions/route.ts:28:  const parsedBody = await parsePaymentsJsonBody(request, paymentSessionCreateSchema);
./app/api/admin/payments/sessions/route.ts:31:    return paymentsInvalidPayloadResponse(parsedBody.error);
./app/api/admin/payments/sessions/route.ts:36:    return paymentsJsonResponse({ ok: true, item: serializePaymentSession(item) }, 201);
./app/api/admin/payments/sessions/route.ts:39:      return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 409);
./app/api/admin/payments/sessions/route.ts:44:      const response = paymentsForeignKeyErrorResponse(fieldName);
./app/api/admin/payments/sessions/[sessionId]/route.ts:7:  paymentSessionUpdateSchema,
./app/api/admin/payments/sessions/[sessionId]/route.ts:8:} from "@/lib/contracts/payments";
./app/api/admin/payments/sessions/[sessionId]/route.ts:9:import { deletePaymentSessionInDb, serializePaymentSession, updatePaymentSessionInDb } from "@/lib/payments";
./app/api/admin/payments/sessions/[sessionId]/route.ts:12:  paymentsErrorResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:13:  paymentsForeignKeyErrorResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:14:  paymentsInvalidPayloadResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:15:  paymentsJsonResponse,
./app/api/admin/payments/sessions/[sessionId]/route.ts:16:} from "@/lib/http/payments-route-helpers";
./app/api/admin/payments/sessions/[sessionId]/route.ts:25:  const parsedBody = await parsePaymentsJsonBody(request, paymentSessionUpdateSchema);
./app/api/admin/payments/sessions/[sessionId]/route.ts:28:    return paymentsInvalidPayloadResponse(parsedBody.error);
./app/api/admin/payments/sessions/[sessionId]/route.ts:36:      return paymentsErrorResponse("PAYMENT_SESSION_NOT_FOUND", 404);
./app/api/admin/payments/sessions/[sessionId]/route.ts:39:    return paymentsJsonResponse({ ok: true, item: serializePaymentSession(item) });
./app/api/admin/payments/sessions/[sessionId]/route.ts:42:      return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 409);
./app/api/admin/payments/sessions/[sessionId]/route.ts:47:      const response = paymentsForeignKeyErrorResponse(fieldName);
./app/api/admin/payments/sessions/[sessionId]/route.ts:63:    return paymentsErrorResponse("PAYMENT_SESSION_NOT_FOUND", 404);
./app/api/admin/payments/sessions/[sessionId]/route.ts:66:  return paymentsJsonResponse({ ok: true });
./app/api/admin/summary/route.ts:3:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/admin/summary/route.ts:7:  const mercadoPago = getMercadoPagoConfig();
./app/api/admin/summary/route.ts:37:      payments: {
./app/api/admin/summary/route.ts:40:        orderCreatePath: "/api/integrations/payments/orders",
./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/me/route.ts:30:      console.error("Invalid coach settings payload", parsedResponse.error.flatten(), settings);
./app/api/coaches/me/route.ts:71:      console.error("Invalid coach settings payload", parsedResponse.error.flatten(), settings);
./app/api/integrations/payments/health/route.ts:1:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/integrations/payments/health/route.ts:3:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/health/route.ts:7:  const mercadoPago = getMercadoPagoConfig();
./app/api/integrations/payments/health/route.ts:9:  return paymentsJsonResponse({
./app/api/integrations/payments/health/route.ts:11:    integration: "payments",
./app/api/integrations/payments/health/route.ts:15:    orderCreatePath: "/api/integrations/payments/orders",
./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:19:      accessTokenConfigured: Boolean(mercadoPago.accessToken),
./app/api/integrations/payments/health/route.ts:21:      webhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
./app/api/integrations/payments/health/route.ts:23:      externalPosIdConfigured: Boolean(mercadoPago.externalPosId),
./app/api/integrations/payments/health/route.ts:25:      apiBaseUrl: mercadoPago.publicApiBaseUrl,
./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:1:import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
./app/api/integrations/payments/sessions/route.ts:2:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/integrations/payments/sessions/route.ts:4:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/sessions/route.ts:9:  const mercadoPago = getMercadoPagoConfig();
./app/api/integrations/payments/sessions/route.ts:11:  return paymentsJsonResponse({
./app/api/integrations/payments/sessions/route.ts:13:    integration: "payments",
./app/api/integrations/payments/sessions/route.ts:17:    orderCreatePath: "/api/integrations/payments/orders",
./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),
./app/api/integrations/payments/orders/route.ts:3:  paymentOrderCreateResponseSchema,
./app/api/integrations/payments/orders/route.ts:4:  paymentOrderCreateSchema,
./app/api/integrations/payments/orders/route.ts:5:} from "@/lib/contracts/payments";
./app/api/integrations/payments/orders/route.ts:8:  paymentsErrorResponse,
./app/api/integrations/payments/orders/route.ts:9:  paymentsInvalidPayloadResponse,
./app/api/integrations/payments/orders/route.ts:10:  paymentsJsonResponse,
./app/api/integrations/payments/orders/route.ts:11:} from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/orders/route.ts:12:import { createMercadoPagoQrOrder, getMercadoPagoConfig } from "@/lib/payments-mercadopago";
./app/api/integrations/payments/orders/route.ts:13:import { buildPaymentExternalReference, getPaymentProductDefaultPriceMinor } from "@/lib/payments";
./app/api/integrations/payments/orders/route.ts:23:  return paymentsJsonResponse({
./app/api/integrations/payments/orders/route.ts:39:  const parsedBody = await parsePaymentsJsonBody(request, paymentOrderCreateSchema);
./app/api/integrations/payments/orders/route.ts:41:    return paymentsInvalidPayloadResponse(parsedBody.error);
./app/api/integrations/payments/orders/route.ts:49:    return paymentsErrorResponse("VENDING_MACHINE_NOT_FOUND", 404);
./app/api/integrations/payments/orders/route.ts:57:    return paymentsErrorResponse("PRODUCT_NOT_FOUND", 404);
./app/api/integrations/payments/orders/route.ts:63:    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
./app/api/integrations/payments/orders/route.ts:65:      message: "Missing external POS id in payload and BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID",
./app/api/integrations/payments/orders/route.ts:76:    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
./app/api/integrations/payments/orders/route.ts:83:    return paymentsErrorResponse("INVALID_PAYLOAD", 400, {
./app/api/integrations/payments/orders/route.ts:106:    return paymentsErrorResponse("PAYMENT_SESSION_CONFLICT", 502, {
./app/api/integrations/payments/orders/route.ts:113:  return paymentsJsonResponse(paymentOrderCreateResponseSchema.parse({ ok: true, item: createResult.value }), 201);
./app/api/clubs/coaches/route.ts:34:    console.error("Invalid club coaches response payload", parsed.error.flatten());
./app/api/integrations/payments/approved/route.ts:1:import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
./app/api/integrations/payments/approved/route.ts:6:  return paymentsJsonResponse(
./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:17:  return paymentsJsonResponse(
./app/api/integrations/payments/approved/route.ts:21:      message: "Use the native MercadoPago webhook at /notification/mercadopago.",

[Showing lines 1494-1949 of 1949 (50.0KB limit). Full output: /tmp/agent-subagent/pi-bash-b07e5e28ad059935.log]

[toolResult]
# TCN payments integration plan

## Decision
- Treat the current `/admin/payments` QR generator as an operator test harness, not the final production payment UX for vending machines.
- Production vending flow should integrate with the TCN machine protocol and let the machine render the buyer-facing QR on its own screen.

## Why
- TCN protocol `2.5V` states: `to scan QR codes additional hardware devices are necessary.`
- That wording applies to **machine-side QR scanning**, not to the machine displaying a QR on its screen for the buyer to scan with a phone.
- The payment section (`2.1.5 Integral exchange`, `FunCode=8000`) already defines a dynamic payment flow:
  - machine sends `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`
  - server returns `CodeUrl`, `TradeNo`
- `CodeUrl` is described as the returned value used to generate the QR code.
- That is enough to proceed with integration planning without assuming extra scanner hardware.

## Intended production flow
1. Buyer selects product on TCN machine.
2. TCN machine requests Ballbox payment session using the TCN payment contract.
3. Ballbox creates a Mercado Pago QR order.
4. Ballbox maps the Mercado Pago output into the TCN response shape and returns `CodeUrl`.
5. TCN machine renders the QR on its screen.
6. Buyer scans with Mercado Pago app on phone.
7. Payment provider notifies Ballbox webhook.
8. Ballbox validates/persists approved payment.
9. TCN machine receives the success signal through the TCN-side notify/result path and dispenses product.

## Ballbox work now
### 1. Lock the machine-facing contract
- Define a Ballbox route for TCN `8000` payment-init requests.
- Define exact request/response mapping for:
  - `MachineID` -> Ballbox `vendingMachineId` or external machine identifier
  - `TradeNo` -> machine transaction id / canonical `externalReference`
  - `SlotNo`
  - `Price`
  - `NotityUrl`
  - response `CodeUrl`
- Confirm whether TCN expects raw payment URL, QR payload string, or a specific provider deep link in `CodeUrl`.

### 2. Define result/dispense handshake
- Map how Ballbox should signal payment success back into the TCN flow.
- Verify whether Ballbox must call `NotityUrl`, expose a callback endpoint, or rely on a polling/result interface in the broader TCN protocol set.
- Confirm how TCN links approved payment to dispense using `TradeNo` and/or `SlotNo`.

### 3. Normalize machine identity
- Decide the canonical mapping between TCN `MachineID` and Ballbox `VendingMachine`.
- Add any missing external-id field only if needed for clean lookup.

### 4. Keep admin QR page as a test tool
- `/admin/payments` remains useful for:
  - Mercado Pago config checks
  - live QR creation smoke tests
  - webhook validation
  - manual provider debugging
- Do not frame it as the final vending-machine payment UX.

## Unknowns still to close
- Exact payload expected inside TCN `CodeUrl`.
- Exact success callback / notify shape between Ballbox and TCN after payment approval.
- Whether machine-side polling (`4000`) participates in payment completion or only app-driven vend/load flows.
- Whether TCN needs additional payment-status endpoints beyond `8000` for a full dispense flow.

## Recommendation
- Next implementation slice should be a TCN adapter spec + stub endpoints, not more work on the standalone admin QR page.
- Keep Mercado Pago-native Ballbox routes as the provider layer underneath that adapter.

## Evidence
- `test-results/tcn-payment-protocol/Protocol 2.5V English version.pdf`
- `test-results/tcn-payment-protocol/findings-2026-04-23.md`

[toolResult]
# TCN payments open questions

## Goal
Close the remaining machine-integration unknowns before Ballbox implements the full TCN adapter.

## Questions for TCN sales / engineering
1. In `FunCode=8000`, what exact value should Ballbox return in `CodeUrl`?
   - raw URL
   - QR payload string
   - Mercado Pago deep link
   - other provider-specific format
2. After payment approval, how does the machine learn that the vend can proceed?
   - Ballbox calls the request `NotityUrl`
   - machine polls another TCN endpoint
   - cloud pushes through `4000`
   - other flow
3. Is `NotityUrl` always machine-provided per transaction, or is there also a machine-level configured callback?
4. What is the exact payload shape expected for the payment-success notify/result message?
5. Which field is the true dispense correlation key?
   - `TradeNo`
   - `SlotNo`
   - both
   - another internal machine field
6. Does the stock TCN app render the QR natively after a successful `8000` response, or is any machine-side UI configuration required?
7. Are there any timeout / expiry expectations for the QR payment window from the machine side?
8. What should Ballbox return to the machine when payment creation fails or times out?
9. Is there any machine retry behavior Ballbox must tolerate for duplicate `TradeNo` requests?
10. Are there additional payment-related endpoints beyond `8000` required for a production dispense flow?

## Questions for Mercado Pago / internal validation
1. Which returned value from Ballbox should be safest to map into TCN `CodeUrl` for dynamic QR display?
2. Does current Ballbox order creation produce the same QR/deeplink payload shape expected by a buyer using Mercado Pago app?
3. Is the current webhook exposure stable enough for real public notifications on the production-like Ballbox URL?
4. Are there provider-side expiry, duplicate-order, or idempotency constraints we should encode before machine rollout?

## Output expected from this doc
- A resolved field-mapping note added back into `docs/tcn-payments-integration-plan.md`
- A concrete implementation spec once answers arrive

[toolResult]
# Payments machine implementation RFC

## Status
- Proposed technical implementation slice for `PAY-3`.
- Builds on `docs/payments-machine-rollout-rfc.md`.

## Goal
Implement one machine-payment flow in Ballbox that:
- serves TCN machines
- creates Mercado Pago QR orders
- persists approved payments through the Ballbox webhook
- supports two payment-owner modes without forking the stack:
  - `BALLBOX`
  - `TENANT`

## Non-goals
- full split settlement automation
- tenant self-serve onboarding
- final long-term tenant account admin UX
- production-grade accounting ledger in this slice

## Current code baseline
Existing implemented pieces:
- order create route: `app/api/integrations/payments/orders/route.ts`
- webhook intake: `app/notification/mercadopago/route.ts`
- provider helper: `lib/payments-mercadopago.ts`
- payment persistence + correlation: `lib/payments.ts`
- payment contracts: `lib/contracts/payments.ts`

Current limitation:
- provider config is effectively global via Ballbox env (`BALLBOX_MERCADOPAGO_*`)
- webhook fetch path currently assumes one Mercado Pago config
- machine-facing TCN adapter does not exist yet

## Proposed architecture

### 1. Separate provider config resolution from raw env access
Create a resolver layer that answers:
- which payment-owner mode applies to this machine/request/order
- which Mercado Pago config should be used

Proposed concept:
- `lib/payments-mercadopago-config.ts`

Responsibilities:
- resolve `BALLBOX` default config from env
- resolve `TENANT` config from a Ballbox-owned machine/tenant mapping source
- return a normalized config object used by both order-create and webhook-fetch flows

Suggested shape:
```ts
export type PaymentOwnerMode = "BALLBOX" | "TENANT";

export type MercadoPagoConfigResolved = {
  ownerMode: PaymentOwnerMode;
  ownerKey: string; // e.g. ballbox-default or tenant/adidas-001
  accessToken: string;
  webhookSecret: string | null;
  externalPosId: string | null;
  publicApiBaseUrl: string;
};
```

## 2. Resolve payment owner per machine
Ballbox needs a deterministic way to know which owner mode applies before creating an order.

Short-term recommendation:
- add machine-level payment ownership fields to the Ballbox data model
- keep it simple and explicit, not inferred from club type or naming

Suggested fields at machine level:
- `paymentOwnerMode`
- `paymentOwnerKey`
- optional override `paymentExternalPosId`

Meaning:
- `paymentOwnerMode`: `BALLBOX` or `TENANT`
- `paymentOwnerKey`: resolver key for the MP config to use
- `paymentExternalPosId`: optional machine-specific override when not using the owner default POS

If schema change should stay smaller in step 1, fallback is:
- temporary in-code or env-backed mapping by `vendingMachineId`
- but only as a bridge; repo docs should still target DB-backed ownership

## 3. Preserve owner resolution in the order correlation path
Webhook processing must re-discover the correct MP credentials later.

Today Ballbox correlation key is:
- `payment-vm_{vendingMachineId}-txn_{machineTxnId}`

Keep that canonical reference.
But add enough persistence so webhook-triggered fetch can derive config without guessing.

Recommended minimum persisted fields on payment/session side:
- `vendingMachineId`
- canonical `externalReference`
- provider order id
- `paymentOwnerMode`
- `paymentOwnerKey`

If `PaymentSession` should remain approved-only and lean, add a lightweight pre-approval payment operation record instead.

## 4. Introduce a pre-approval payment operation record
Reason:
- webhook can arrive before a final approved `PaymentSession` exists
- Ballbox needs a stable place to remember which config created which provider order

Recommendation:
- add a lightweight table for initiated machine payments

Suggested record name:
- `PaymentOperation`

Suggested fields:
- `id`
- `vendingMachineId`
- `machineTxnId`
- `externalReference`
- `provider` (`MERCADOPAGO`)
- `providerOrderId`
- `paymentOwnerMode`
- `paymentOwnerKey`
- `externalPosId`
- `amountMinor`
- `status` (`CREATED`, `APPROVED`, `FAILED`, `EXPIRED`)
- `tenantLabel` or `notes` optional
- timestamps

Why this helps:
- webhook can resolve order id -> `PaymentOperation` -> correct MP config
- Ballbox can detect duplicate machine requests by `externalReference`
- later vend/result handling has a more natural source row than only approved sessions

## 5. Refactor provider helpers to accept explicit config
Current helpers mostly read env internally.
That blocks multi-owner support.

Refactor target:
- `fetchMercadoPagoOrder(orderId, config)`
- `createMercadoPagoQrOrder(input, config)`
- `getMercadoPagoConfig()` becomes only the `BALLBOX` default resolver or disappears behind the resolver module

Rule:
- business flows decide config first
- provider helpers never decide owner mode themselves

## 6. Add a machine-facing service layer before routes
Suggested module:
- `lib/payments-machine-flow.ts`

Responsibilities:
- resolve machine owner mode/config
- build canonical external reference
- create or reuse payment operation row
- call Mercado Pago order create
- return Ballbox-normalized result for either admin or TCN routes

Suggested main functions:
```ts
createMachinePaymentSession({
  vendingMachineId,
  machineTxnId,
  amountMinor,
  productId,
  source, // admin | tcn
})

resolvePaymentOwnerForMachine(vendingMachineId)

recordWebhookApproval({ orderId, externalReference, ... })
```

## 7. TCN route should sit on top of the machine-payment service
Recommendation:
- do not embed MP logic directly in TCN route handlers
- TCN route should translate between TCN contract and Ballbox machine-payment service only

Proposed route family:
- `app/api/integrations/tcn/payments/init/route.ts` or similar

That route should:
- parse TCN request
- resolve machine + amount + trade number
- call machine-payment service
- return TCN response shape including `CodeUrl`

## 8. Keep admin QR route as test harness over the same service
Admin route should reuse the same service layer where possible.
That avoids a separate payment stack for admin testing vs machine runtime.

Result:
- `/api/integrations/payments/orders` becomes a Ballbox test/operator entrypoint
- TCN adapter becomes the real machine entrypoint
- both converge on one machine-payment service underneath

## Data model recommendation

### Preferred
Add machine ownership to `VendingMachine` and payment initiation state to a new `PaymentOperation` table.

### Why preferred
- machine-level owner mode is operational truth
- webhook correlation becomes reliable
- future reporting becomes easier
- tenant setup does not need route-local hacks or env naming tricks

### Minimum viable fallback if schema must wait
- machine owner config stored in a local mapping module keyed by `vendingMachineId`
- provider order id + owner key persisted in a file/temporary table substitute

This fallback is weaker and should be temporary only.

## Webhook resolution strategy

### Preferred path
1. webhook payload yields `orderId`
2. Ballbox tries to find `PaymentOperation` by `providerOrderId`
3. from that row Ballbox gets `paymentOwnerMode` + `paymentOwnerKey`
4. Ballbox resolves MP config
5. Ballbox fetches order from MP
6. Ballbox maps approved result
7. Ballbox persists/updates `PaymentSession`
8. Ballbox marks `PaymentOperation` as approved

### Fallback path if operation row missing
1. parse `externalReference` from fetched order
2. derive `vendingMachineId`
3. resolve machine owner mode from DB
4. retry or continue

Use fallback only as recovery, not primary mechanism.

## Idempotency rules
- unique key on canonical `externalReference`
- unique key on `providerOrderId` if possible
- repeated machine `TradeNo` for same machine should reuse or safely reject duplicate order creation
- webhook replays should be no-op once the approval is persisted

## Suggested implementation steps

### Step A
Refactor provider helpers to take explicit config.

### Step B
Add owner resolution layer.

### Step C
Add machine-payment service layer.

### Step D
Add `PaymentOperation` persistence.

### Step E
Move `/api/integrations/payments/orders` onto that service.

### Step F
Add TCN adapter route using the same service.

### Step G
Update webhook route to resolve config from `PaymentOperation` first.

## Testing plan

### Contracts
- order create still validates canonical references
- TCN init route returns expected TCN response shape
- webhook route is idempotent with repeated notifications

### Unit
- owner resolution by machine
- config resolution by owner key
- fallback correlation rules
- duplicate `TradeNo` behavior

### Integration
- `BALLBOX` machine order create -> webhook persist
- `TENANT` machine order create -> webhook persist
- webhook config resolution by `providerOrderId`

## Open questions still external
- exact TCN `CodeUrl` payload expectation
- exact TCN payment-success / dispense handshake
- whether tenant webhook/POS setup needs any special MP product variation

## Recommendation
Implement `PAY-3` around:
- explicit owner resolution
- explicit provider config resolution
- a shared machine-payment service
- a lightweight `PaymentOperation` table

That is the smallest clean shape that supports both first-wave modes without painting Ballbox into a corner.

## Related docs
- `docs/payments-machine-rollout-rfc.md`
- `docs/tcn-payments-integration-plan.md`
- `docs/tcn-payments-execution-plan.md`
- `docs/implemented-payments.md`

[toolResult]
# Payments + machine rollout RFC

## Status
- Proposed operating plan for the first vending rollout.
- Use this as the master document for payment ownership modes, TCN integration, onboarding, and rollout sequencing.

## Decision summary
- Use one Ballbox-controlled technical flow for all vending machines.
- Support two payment-owner modes in that flow:
  - `BALLBOX`: Ballbox owns the Mercado Pago account that collects funds.
  - `TENANT`: the client/tenant owns the Mercado Pago account that collects funds.
- Default first rollout:
  - 3 club machines use `BALLBOX` collection.
  - 2 Adidas rental machines use `TENANT` collection.
- For `BALLBOX` collection, club revenue share is handled by informal/manual settlement at first.
- Do not block rollout on Mercado Pago native split or marketplace approval.
- Keep future split automation as an optional later track, not a prerequisite.

## Why this is the right first plan
- The immediate risk is not payout automation. The immediate risk is getting the machine payment path working end to end.
- TCN machine integration and Mercado Pago webhook persistence are still the highest-risk technical pieces.
- The first wave is small: 5 machines total.
- Manual settlement is acceptable for the 3 Ballbox-collected club machines.
- Adidas-style rental machines need easy setup more than Ballbox ownership of the funds.
- Running all machines through Ballbox keeps one integration surface, one webhook surface, and one observability path.

## Scope
### In scope
- TCN machine payment adapter
- Mercado Pago provider integration through Ballbox
- machine-level payment-owner mode selection
- Ballbox-collected club machines
- tenant-collected Adidas/client machines
- webhook handling through Ballbox
- manual settlement guidance for Ballbox-collected club machines
- onboarding steps that include non-code work

### Out of scope for this phase
- Mercado Pago marketplace/split as a requirement for launch
- fully automated club settlements
- self-serve onboarding for tenants/clubs
- long-term tenant MP support beyond guided setup

## Operating modes

### Mode A: `BALLBOX`
Use for the first 3 club machines.

Flow:
1. machine belongs to a club/venue
2. Ballbox creates the Mercado Pago order with Ballbox credentials
3. buyer pays by scanning the QR shown by the machine
4. Mercado Pago notifies Ballbox webhook
5. Ballbox fetches order details, verifies approval, persists payment
6. Ballbox later settles the club share manually

Properties:
- simplest first rollout
- Ballbox controls setup and debugging
- informal manual settlement is enough at this stage

### Mode B: `TENANT`
Use for the first 2 Adidas rental machines.

Flow:
1. machine belongs to a client/tenant contract
2. Ballbox creates the Mercado Pago order using tenant credentials/config
3. buyer pays by scanning the QR shown by the machine
4. Mercado Pago notifies the same Ballbox webhook endpoint
5. Ballbox fetches order details with the tenant-specific credentials, verifies approval, persists payment
6. tenant owns funds; Ballbox is not doing revenue-share settlement for those payments

Properties:
- Ballbox still owns the technical integration path
- tenant owns the collecting account
- Ballbox should provide guided setup, not heavy day-to-day support

## Why tenant-direct collection should still run through Ballbox
If Adidas/client collection is done outside Ballbox, Ballbox loses:
- unified TCN adapter behavior
- unified webhook and persistence behavior
- centralized debugging for payment-approved-but-no-vend cases
- shared analytics/reporting across machines
- cleaner future migration paths

The better boundary is:
- Ballbox owns the payment orchestration path
- tenant owns the payment account

## Architecture direction

### Core rule
Payment ownership should be resolved per machine or per contract, not globally.

### Required model concept
Each machine should resolve to something like:
- `paymentOwnerMode = BALLBOX | TENANT`
- payment config reference for Mercado Pago
- vending machine identity
- owning club/venue or tenant/customer relationship

### Provider-layer implication
Ballbox needs to choose the right Mercado Pago credentials/config before creating the order and before fetching webhook-triggered order details.

### Webhook implication
Keep one Ballbox webhook endpoint:
- `/notification/mercadopago`

But Ballbox must be able to determine which payment config applies when an event arrives.
That likely means preserving enough correlation to map:
- provider order / external reference -> vending machine -> payment owner mode -> MP config

## TCN integration direction
- TCN remains the machine-facing protocol.
- The machine should render the buyer-facing QR.
- Ballbox should implement the TCN payment adapter over the native Mercado Pago provider layer.
- The Ballbox admin QR page remains a test/debug harness, not the production machine UX.

Related docs:
- `docs/tcn-payments-integration-plan.md`
- `docs/tcn-payments-open-questions.md`
- `docs/tcn-payments-execution-plan.md`

## Rollout phases

### Phase 0. Provider proof
Goal:
- prove Ballbox native Mercado Pago order + webhook persistence works publicly

Output:
- one real approved payment persisted locally

Related work:
- `OPS-1`
- `docs/mercadopago-webhook-replay-notes-2026-04-23.md`

### Phase 1. TCN contract closure
Goal:
- confirm TCN `CodeUrl` and approval/dispense handshake details with sales/engineering

Output:
- precise adapter contract

Related work:
- `PAY-3`
- `docs/tcn-payments-open-questions.md`

### Phase 2. Single technical path with two payment-owner modes
Goal:
- support the same machine flow for both Ballbox-collected and tenant-collected machines

Output:
- machine-level payment owner resolution
- machine-facing TCN adapter over provider layer
- webhook handling that can resolve the correct config

### Phase 3. Guided onboarding for first five machines
Goal:
- launch 3 club machines and 2 Adidas rental machines

Outputs:
- 3 machines on `BALLBOX`
- 2 machines on `TENANT`
- setup checklist followed per machine
- real payment test per machine/account context

### Phase 4. Informal settlement ops for Ballbox-collected clubs
Goal:
- settle club share simply without blocking rollout

Output:
- lightweight manual ledger/export/notes process

### Phase 5. Re-evaluate automation later
Goal:
- only after machine flow is stable, volume increases, or settlement pain becomes real

Possible future paths:
- better internal settlement tooling
- Mercado Pago split / marketplace if it fits the later buyer flow

## Onboarding plan

### Shared machine onboarding
For every machine:
1. identify machine owner mode: `BALLBOX` or `TENANT`
2. map machine to club/venue or tenant contract
3. map machine identifier between TCN and Ballbox
4. confirm product/pricing setup
5. confirm public webhook reachability from Ballbox
6. run one real payment test
7. confirm approval persistence and vend outcome once hardware exists

### Club-machine onboarding (`BALLBOX`)
Needed now for the first 3 machines:
- confirm machine belongs to one club/venue
- confirm commercial revenue-share terms outside code
- confirm Ballbox account is the collecting account
- note settlement percentage/rule outside the provider path
- after launch, settle informally on a fixed cadence

### Adidas/client onboarding (`TENANT`)
Needed now for the first 2 machines:
- confirm they want to collect in their own Mercado Pago account
- guide them through MP setup only enough to make the machine flow work
- confirm they configure Ballbox webhook endpoint in their MP account if required by the final provider setup
- confirm any required token/POS/external POS values
- run one real payment test with their account
- do not promise heavy ongoing support for their Mercado Pago operations

## Operational expectations
- Manual settlement is acceptable for Ballbox-collected club machines in the first wave.
- For tenant-collected machines, Ballbox is facilitating setup, not owning their payout operations.
- No promise exists that clubs must collect directly in their own account.
- Clubs mainly care about net outcome and avoiding extra invoicing burden, not about ownership of the payment rails themselves.

## Risks
### Technical
- TCN approval/dispense handshake still needs confirmation
- tenant-specific webhook/config resolution may require extra correlation fields
- real webhook validation is not fully closed yet

### Operational
- guided Adidas setup may still stall on account/POS/webhook details
- manual club settlements can become messy if volume rises fast
- support boundaries for tenant-owned MP accounts must stay explicit

## Non-goals to enforce
- do not build split-marketplace complexity into the first machine rollout
- do not treat per-club direct collection as the universal default
- do not overbuild settlement tooling before real machine payments work

## Success criteria
- one Ballbox-collected machine completes payment flow end to end
- one tenant-collected machine completes payment flow end to end
- Ballbox persists approved payments for both owner modes
- TCN machine-facing contract is documented and implemented without guessing
- first five machines have an onboarding checklist that can be repeated

## Related docs
- `PLAN.md`
- `docs/implemented-payments.md`
- `docs/tcn-payments-integration-plan.md`
- `docs/tcn-payments-open-questions.md`
- `docs/tcn-payments-execution-plan.md`
- `docs/payments-club-settlement-strategy.md`
- `docs/mercadopago-webhook-replay-notes-2026-04-23.md`

[Assistant]
**Inspecting code files**

I need to take a closer look at the exact code in the relevant files, like lib/contracts/payments.ts and lib/payments.ts. I might also check into Mercadopago and the webhook route. Additionally, it wouldn’t hurt to look at the Prisma schema to see if a payment operation exists there. Let’s make sure I thoroughly inspect everything needed to get the information!

{"id":"rs_01531f35460a6ac0016a0477edbd7481968f708c3b5351126e","type":"reasoning","encrypted_content":"gAAAAABqBHfy_RaekberXN0ffO5bTpsW51UnivbB-5lLm4fyQu4ceBZt2BmK4gAwyDlo3XwqiAHfh827liFA0hshzbGqax9Vmf1LH3tRkenIRJUqAW5MPTRBquWzfK6vijJ_CvfHt5azGC90qfYm6j0fsYFHXs0O_YRvt-t5lNlhYBJ3Kas7uxyOtNfwlycwB12m9ip-EONu_60i8RXPUr2hlCp3muNfjVI_KE3DnyPwNnso6JA3ax0AgyNrxlM4RTUZqO4GMR6vas6I_tE1wB6UeP1oDGSKpBSO4BUrsbjJ431usoUBHpgfe7FfMOhy1G4eu6doe3ZexTl2Ojz91xUapzIEra8b1-69UY1TavakLXtmAvt0511KuuEL4eMSYep3jdWEJ-OA_7JKBKQlRleclEsvDbetWzsCc773zEGJS7uuX0Jvj-F8Tm37gUE0W6WsUY-F1RErpMl5tuPNPNwzd5JuEz1S5A0nb6rVCuNiV8uuT3YZfDfzlZqVI3l4a4B5YEPkf-cGu6PtFf_GNBcfNep5d6eL8jvS2mDY_uGmsnJ_CZkX1GtM_E_TVEs7A6q6qgOatyRj6XnKPP2xEPpwWCfr4oFrzTKcruMS-FB1uzAdzVhs8bPeH5_St9hSV_fgqFs7hhV-OooR-IHO6wfGUYB-rnG3nnQd0VtPTv5H7xWCZTgli6Ig9L68OSVxT7hSzQKGeNWVNEyi-iC2CnaM1fB4mNSucyJHCLCX_qevD73fnEMUx0GqzpTH8lm1NqFimINNdx2FvOsLUsVJ6BuMv7gu4RBAsHpTtyh_EVgcY4HD3gMTIFyCg5-8HGZj-8wSZXnno8LPNU-YrvCm36jf0gdKoOtGthpO731YZCfKQVK4fYeuLHWSNZc-tNMd74mW8ytdCc1-gFfgYkQAVTYqNDbb9RcUkV-QewD4n7UPHytTSGMIrVnLT6yVYuabUprMx74cS3SNrFSoFpBtGFCOBGV_dQZAhCnJXmX41fMzmPY0ncCfYyE8rGl--ACJTZDCRhCXLyyLtnc40YRhe5F-tzj3F4SBm6IdDyn2cnnFXkCHRTjYdefIRdlZQfmq-0wk1_jITWrPexAdP8I2-Jwp9DHm6J7uoYFD7v4EsQqeHugG19l_KQFsoPiFJKaC963GosK1dxMw","summary":[{"type":"summary_text","text":"**Inspecting code files**\n\nI need to take a closer look at the exact code in the relevant files, like lib/contracts/payments.ts and lib/payments.ts. I might also check into Mercadopago and the webhook route. Additionally, it wouldn’t hurt to look at the Prisma schema to see if a payment operation exists there. Let’s make sure I thoroughly inspect everything needed to get the information!"}]}

[toolResult]
import { z } from "zod";

const paymentDateTimeSchema = z
  .string()
  .trim()
  .min(1, "Approved time is required")
  .refine((value) => !Number.isNaN(Date.parse(value)), "Approved time must be a valid datetime");

export const paymentSessionStatusSchema = z.enum(["approved"]);

export const paymentsAdminErrorCodeSchema = z.enum([
  "INVALID_PAYLOAD",
  "PAYMENT_SESSION_NOT_FOUND",
  "PAYMENT_SESSION_CONFLICT",
  "VENDING_MACHINE_NOT_FOUND",
  "PRODUCT_NOT_FOUND",
]);

export const paymentsAdminErrorResponseSchema = z
  .object({
    ok: z.literal(false),
    error: paymentsAdminErrorCodeSchema,
    status: z.number().int(),
    details: z.record(z.string(), z.unknown()).optional(),
  })
  .strict();

export const paymentSessionCreateSchema = z.object({
  sessionId: z.string().trim().min(1, "Session id is required").max(191, "Session id is too long"),
  vendingMachineId: z.string().trim().min(1, "Vending machine id is required").max(191, "Vending machine id is too long"),
  productId: z.string().trim().min(1, "Product id is required").max(191, "Product id is too long"),
  machineTxnId: z.string().trim().min(1, "Machine transaction id is required").max(191, "Machine transaction id is too long"),
  externalReference: z.string().trim().min(1, "External reference is required").max(191, "External reference is too long"),
  amountMinor: z.coerce.number().int().min(0, "Amount must be zero or greater"),
  currency: z.string().trim().min(1, "Currency is required").max(16, "Currency is too long"),
  status: paymentSessionStatusSchema.default("approved"),
  approvedAt: paymentDateTimeSchema,
  lateApproval: z.coerce.boolean().default(false),
  providerPaymentId: z.string().trim().max(191, "Provider payment id is too long").optional(),
  source: z.string().trim().min(1, "Source is required").max(191, "Source is too long").default("admin-manual"),
});

export const paymentApprovalCallbackSchema = z.object({
  sessionId: z.string().trim().min(1, "Session id is required").max(191, "Session id is too long"),
  vendingMachineId: z.string().trim().min(1, "Vending machine id is required").max(191, "Vending machine id is too long"),
  productId: z.string().trim().min(1, "Product id is required").max(191, "Product id is too long"),
  machineTxnId: z.string().trim().min(1, "Machine transaction id is required").max(191, "Machine transaction id is too long"),
  externalReference: z.string().trim().min(1, "External reference is required").max(191, "External reference is too long"),
  amountMinor: z.coerce.number().int().min(0, "Amount must be zero or greater"),
  currency: z.string().trim().min(1, "Currency is required").max(16, "Currency is too long"),
  status: paymentSessionStatusSchema,
  approvedAt: paymentDateTimeSchema,
  lateApproval: z.coerce.boolean(),
  providerPaymentId: z.string().trim().min(1, "Provider payment id is required").max(191, "Provider payment id is too long"),
});

export const paymentSessionUpdateSchema = z
  .object({
    vendingMachineId: z
      .string()
      .trim()
      .min(1, "Vending machine id is required")
      .max(191, "Vending machine id is too long")
      .optional(),
    productId: z.string().trim().min(1, "Product id is required").max(191, "Product id is too long").optional(),
    machineTxnId: z
      .string()
      .trim()
      .min(1, "Machine transaction id is required")
      .max(191, "Machine transaction id is too long")
      .optional(),
    externalReference: z
      .string()
      .trim()
      .min(1, "External reference is required")
      .max(191, "External reference is too long")
      .optional(),
    amountMinor: z.coerce.number().int().min(0, "Amount must be zero or greater").optional(),
    currency: z.string().trim().min(1, "Currency is required").max(16, "Currency is too long").optional(),
    approvedAt: paymentDateTimeSchema.optional(),
    lateApproval: z.coerce.boolean().optional(),
    providerPaymentId: z.string().trim().max(191, "Provider payment id is too long").optional(),
    source: z.string().trim().min(1, "Source is required").max(191, "Source is too long").optional(),
  })
  .refine(
    (value) =>
      value.vendingMachineId !== undefined ||
      value.productId !== undefined ||
      value.machineTxnId !== undefined ||
      value.externalReference !== undefined ||
      value.amountMinor !== undefined ||
      value.currency !== undefined ||
      value.approvedAt !== undefined ||
      value.lateApproval !== undefined ||
      value.providerPaymentId !== undefined ||
      value.source !== undefined,
    {
      message: "At least one field is required",
    }
  );

export const paymentOrderModeSchema = z.enum(["static", "dynamic", "hybrid"]);

export const paymentOrderCreateSchema = z.object({
  vendingMachineId: z.string().trim().min(1, "Vending machine id is required").max(191, "Vending machine id is too long"),
  productId: z.string().trim().min(1, "Product id is required").max(191, "Product id is too long"),
  amountMinor: z.coerce.number().int().min(1, "Amount must be greater than zero").optional(),
  currency: z.string().trim().min(1, "Currency is required").max(16, "Currency is too long").default("ARS"),
  mode: paymentOrderModeSchema.default("dynamic"),
  externalPosId: z.string().trim().min(1, "External POS id is required").max(191, "External POS id is too long").optional(),
  description: z.string().trim().max(150, "Description is too long").optional(),
  expirationTime: z
    .string()
    .trim()
    .min(1, "Expiration time is required")
    .max(32, "Expiration time is too long")
    .default("PT15M"),
});

export const paymentOrderCreateResponseSchema = z
  .object({
    ok: z.literal(true),
    item: z
      .object({
        orderId: z.string(),
        externalReference: z.string(),
        vendingMachineId: z.string(),
        productId: z.string(),
        machineTxnId: z.string(),
        amountMinor: z.number().int(),
        currency: z.string(),
        mode: paymentOrderModeSchema,
        externalPosId: z.string(),
        status: z.string(),
        statusDetail: z.string(),
        expiresAt: z.string().nullable(),
        qrData: z.string().nullable(),
      })
      .strict(),
  })
  .strict();

export type PaymentsAdminErrorCode = z.infer<typeof paymentsAdminErrorCodeSchema>;

export function createPaymentsAdminErrorResponse(
  error: PaymentsAdminErrorCode,
  status: number,
  details?: Record<string, unknown>
) {
  return paymentsAdminErrorResponseSchema.parse({
    ok: false,
    error,
    status,
    ...(details ? { details } : {}),
  });
}

[toolResult]
import { prisma } from "@/lib/prisma";

const paymentProductPriceMinorById: Record<string, number> = {
  product_tubo_pelotas: 100000,
  product_pack_premium: 250000,
};

export function getPaymentProductDefaultPriceMinor(productId: string) {
  return paymentProductPriceMinorById[productId] ?? null;
}

export type PaymentApprovalCallbackInput = {
  sessionId: string;
  vendingMachineId: string;
  productId: string;
  machineTxnId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  status: "approved";
  approvedAt: string;
  lateApproval: boolean;
  providerPaymentId: string;
};

type PaymentSessionRecord = Awaited<ReturnType<typeof listPaymentSessionsFromDb>>[number];

function buildRawPaymentPayload(input: {
  sessionId: string;
  vendingMachineId: string;
  productId: string;
  machineTxnId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  approvedAt: string;
  lateApproval: boolean;
  providerPaymentId?: string | null;
  source: string;
}) {
  return {
    sessionId: input.sessionId,
    vendingMachineId: input.vendingMachineId,
    productId: input.productId,
    machineTxnId: input.machineTxnId,
    externalReference: input.externalReference,
    amountMinor: input.amountMinor,
    currency: input.currency,
    status: "approved",
    approvedAt: input.approvedAt,
    lateApproval: input.lateApproval,
    providerPaymentId: input.providerPaymentId ?? null,
    source: input.source,
  };
}

export function buildPaymentExternalReference(input: { vendingMachineId: string; machineTxnId: string }) {
  return `payment-vm_${input.vendingMachineId}-txn_${input.machineTxnId}`;
}

export function parsePaymentExternalReference(externalReference: string) {
  const match = externalReference.match(/^payment-vm_(.+)-txn_(.+)$/);

  if (!match) {
    return null;
  }

  const [, vendingMachineId, machineTxnId] = match;
  if (!vendingMachineId || !machineTxnId) {
    return null;
  }

  return {
    prefix: "payment",
    vendingMachineId,
    machineTxnId,
  };
}

export function getPaymentReferenceCorrelation(input: {
  externalReference: string;
  vendingMachineId: string;
  machineTxnId: string;
}) {
  const parsedReference = parsePaymentExternalReference(input.externalReference);

  return {
    parsedReference,
    externalReferenceIsCanonical: parsedReference !== null,
    vendingMachineIdMatchesReference: parsedReference ? parsedReference.vendingMachineId === input.vendingMachineId : false,
    machineTxnIdMatchesReference: parsedReference ? parsedReference.machineTxnId === input.machineTxnId : false,
  };
}

export function serializePaymentSession(item: PaymentSessionRecord) {
  const correlation = getPaymentReferenceCorrelation({
    externalReference: item.externalReference,
    vendingMachineId: item.vendingMachineId,
    machineTxnId: item.machineTxnId,
  });

  return {
    sessionId: item.id,
    vendingMachineId: item.vendingMachineId,
    productId: item.productId,
    machineTxnId: item.machineTxnId,
    externalReference: item.externalReference,
    amountMinor: item.amountMinor,
    currency: item.currency,
    status: item.status.toLowerCase(),
    approvedAt: item.approvedAt.toISOString(),
    lateApproval: item.lateApproval,
    providerPaymentId: item.providerPaymentId,
    source: item.source,
    correlation,
  };
}

export type SerializedPaymentSession = ReturnType<typeof serializePaymentSession>;

export async function recordApprovedPayment(input: PaymentApprovalCallbackInput) {
  const approvedAt = new Date(input.approvedAt);

  const payment = await prisma.paymentSession.upsert({
    where: { id: input.sessionId },
    update: {
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId: input.providerPaymentId,
      rawPayload: input,
    },
    create: {
      id: input.sessionId,
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      status: "APPROVED",
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId: input.providerPaymentId,
      rawPayload: input,
    },
  });

  return payment;
}

export async function listRecentApprovedPayments(limit = 20) {
  return listPaymentSessionsFromDb(limit);
}

export async function listPaymentSessionsFromDb(limit = 50) {
  return prisma.paymentSession.findMany({
    orderBy: { approvedAt: "desc" },
    take: limit,
  });
}

export async function listPaymentQrOptionsFromDb() {
  const [products, vendingMachines] = await Promise.all([
    prisma.product.findMany({
      orderBy: { name: "asc" },
      select: { id: true, name: true },
    }),
    prisma.vendingMachine.findMany({
      orderBy: { label: "asc" },
      select: { id: true, label: true, venueId: true },
    }),
  ]);

  return {
    products: products.map((product) => ({
      ...product,
      priceMinor: getPaymentProductDefaultPriceMinor(product.id),
    })),
    vendingMachines,
  };
}

export async function getPaymentsAdminDataFromDb() {
  const items = await listPaymentSessionsFromDb();
  const serializedItems = items.map(serializePaymentSession);
  const mismatchCount = serializedItems.filter(
    (item) => !item.correlation.vendingMachineIdMatchesReference || !item.correlation.machineTxnIdMatchesReference
  ).length;
  const amountTotals = Array.from(
    items.reduce((totals, item) => {
      totals.set(item.currency, (totals.get(item.currency) ?? 0) + item.amountMinor);
      return totals;
    }, new Map<string, number>())
  ).map(([currency, amountMinor]) => ({
    currency,
    amountMinor,
  }));

  return {
    source: "ballbox postgres",
    summary: {
      sessions: items.length,
      vendingMachines: new Set(items.map((item) => item.vendingMachineId)).size,
      lateApprovals: items.filter((item) => item.lateApproval).length,
      correlationMismatches: mismatchCount,
      amountTotals,
    },
    items: serializedItems,
  };
}

export type PaymentsAdminData = Awaited<ReturnType<typeof getPaymentsAdminDataFromDb>>;

export async function createPaymentSessionInDb(input: {
  sessionId: string;
  vendingMachineId: string;
  productId: string;
  machineTxnId: string;
  externalReference: string;
  amountMinor: number;
  currency: string;
  approvedAt: string;
  lateApproval: boolean;
  providerPaymentId?: string;
  source: string;
}) {
  const approvedAt = new Date(input.approvedAt);

  return prisma.paymentSession.create({
    data: {
      id: input.sessionId,
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      status: "APPROVED",
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId: input.providerPaymentId?.trim() ? input.providerPaymentId : null,
      source: input.source,
      rawPayload: buildRawPaymentPayload({
        sessionId: input.sessionId,
        vendingMachineId: input.vendingMachineId,
        productId: input.productId,
        machineTxnId: input.machineTxnId,
        externalReference: input.externalReference,
        amountMinor: input.amountMinor,
        currency: input.currency,
        approvedAt: approvedAt.toISOString(),
        lateApproval: input.lateApproval,
        providerPaymentId: input.providerPaymentId,
        source: input.source,
      }),
    },
  });
}

export async function updatePaymentSessionInDb(
  sessionId: string,
  input: {
    vendingMachineId?: string;
    productId?: string;
    machineTxnId?: string;
    externalReference?: string;
    amountMinor?: number;
    currency?: string;
    approvedAt?: string;
    lateApproval?: boolean;
    providerPaymentId?: string;
    source?: string;
  }
) {
  const existing = await prisma.paymentSession.findUnique({
    where: { id: sessionId },
  });

  if (!existing) return null;

  const approvedAt = input.approvedAt ? new Date(input.approvedAt) : existing.approvedAt;
  const providerPaymentId = input.providerPaymentId !== undefined ? input.providerPaymentId.trim() || null : existing.providerPaymentId;
  const next = await prisma.paymentSession.update({
    where: { id: sessionId },
    data: {
      vendingMachineId: input.vendingMachineId,
      productId: input.productId,
      machineTxnId: input.machineTxnId,
      externalReference: input.externalReference,
      amountMinor: input.amountMinor,
      currency: input.currency,
      approvedAt,
      lateApproval: input.lateApproval,
      providerPaymentId,
      source: input.source,
      rawPayload: buildRawPaymentPayload({
        sessionId,
        vendingMachineId: input.vendingMachineId ?? existing.vendingMachineId,
        productId: input.productId ?? existing.productId,
        machineTxnId: input.machineTxnId ?? existing.machineTxnId,
        externalReference: input.externalReference ?? existing.externalReference,
        amountMinor: input.amountMinor ?? existing.amountMinor,
        currency: input.currency ?? existing.currency,
        approvedAt: approvedAt.toISOString(),
        lateApproval: input.lateApproval ?? existing.lateApproval,
        providerPaymentId,
        source: input.source ?? existing.source,
      }),
    },
  });

  return next;
}

export async function deletePaymentSessionInDb(sessionId: string) {
  const existing = await prisma.paymentSession.findUnique({
    where: { id: sessionId },
    select: { id: true },
  });

  if (!existing) return false;

  await prisma.paymentSession.delete({
    where: { id: sessionId },
  });

  return true;
}

[toolResult]
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?: string | number;
  date_created?: string;
  status?: string;
  order_status?: string;
  payments?: Array<{
    id?: string | number;
    status?: string;
    status_detail?: string;
  }>;
  items?: Array<{
    id?: string | number;
    external_code?: 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 parseMercadoPagoAmountToMinorUnits(amount: string | number | undefined) {
  const numericAmount = typeof amount === "string" ? Number(amount.trim()) : amount;
  return Number.isFinite(numericAmount) ? Math.round((numericAmount as number) * 100) : NaN;
}

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 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 firstItem = order.items?.[0];
  const productId =
    firstItem?.id !== undefined && firstItem?.id !== null
      ? String(firstItem.id)
      : firstItem?.external_code !== undefined && firstItem?.external_code !== null
        ? String(firstItem.external_code)
        : "";
  if (!productId) {
    return {
      ok: false,
      reason: "MERCADOPAGO_ORDER_PRODUCT_ID_MISSING",
    };
  }

  const amountMinor = parseMercadoPagoAmountToMinorUnits(order.total_amount);
  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,
      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;
}) {
  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" }],
        },
      ],
    }),
  });

  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,
    },
  };
}

[toolResult]
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 { 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 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,
  });

  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);
}

[toolResult]
import { NextRequest } from "next/server";
import { recordApprovedPayment, serializePaymentSession } from "@/lib/payments";
import {
  extractMercadoPagoOrderId,
  fetchMercadoPagoOrder,
  mapMercadoPagoOrderToApprovedPayment,
} from "@/lib/payments-mercadopago";

export async function POST(request: NextRequest) {
  const payload = (await request.json().catch(() => ({}))) as {
    id?: string | number;
    data?: { id?: string | number };
    resource?: string;
    action?: string;
    type?: string;
    live_mode?: boolean;
  };

  const requestId = request.headers.get("x-request-id");
  const orderId = extractMercadoPagoOrderId(payload, request.url);

  if (payload.type === "topic_merchant_order_wh") {
    console.info("[payments][mercadopago-webhook] ignored", {
      orderId,
      requestId,
      reason: "MERCADOPAGO_ORDER_TOPIC_UNSUPPORTED",
      action: payload.action ?? null,
      type: payload.type ?? null,
      liveMode: payload.live_mode ?? null,
    });
    return Response.json(
      {
        ok: true,
        ignored: true,
        reason: "MERCADOPAGO_ORDER_TOPIC_UNSUPPORTED",
      },
      { status: 202 }
    );
  }

  const orderResult = await fetchMercadoPagoOrder(orderId as string);
  if (!orderResult.ok) {
    console.warn("[payments][mercadopago-webhook] order fetch failed", {
      orderId,
      requestId,
      error: orderResult.error,
      status: orderResult.status,
      action: payload.action ?? null,
      type: payload.type ?? null,
      liveMode: payload.live_mode ?? null,
    });
    return Response.json(
      {
        ok: false,
        error: orderResult.error,
      },
      { status: orderResult.status === 404 ? 404 : 502 }
    );
  }

  const mapped = mapMercadoPagoOrderToApprovedPayment(orderResult.order);
  if (!mapped.ok) {
    console.info("[payments][mercadopago-webhook] ignored", {
      orderId,
      requestId,
      reason: mapped.reason,
      action: payload.action ?? null,
      type: payload.type ?? null,
      liveMode: payload.live_mode ?? null,
    });
    return Response.json(
      {
        ok: true,
        ignored: true,
        reason: mapped.reason,
      },
      { status: 202 }
    );
  }

  const saved = await recordApprovedPayment(mapped.value);
  console.info("[payments][mercadopago-webhook] approved persisted", {
    orderId,
    requestId,
    sessionId: saved.id,
    action: payload.action ?? null,
    type: payload.type ?? null,
    liveMode: payload.live_mode ?? null,
  });

  return Response.json({
    ok: true,
    source: "mercadopago-webhook",
    item: serializePaymentSession(saved),
  });
}

[toolResult]
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
}

enum CoachStudentBookingPaymentStatus {
  PENDING
  PAID
}

enum PlayerGender {
  FEMALE
  MALE
  OTHER
}

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[]
  coachPriceOverrides VenueCoachPriceOverride[]
  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)
  assignedCoaches Coach[]
  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 VenueCoachPriceOverride {
  id        String   @id @default(cuid())
  venue     Venue    @relation(fields: [venueId], references: [id])
  venueId   String
  dayOfWeek Int
  startTime String
  endTime   String
  price     Int
  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?
  assignedCourt   VenueCourt?   @relation(fields: [assignedCourtId], references: [id])
  assignedCourtId 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])
}

model CoachSalesPackage {
  id              String                  @id @default(cuid())
  coach           Coach                   @relation(fields: [coachId], references: [id])
  coachId         String
  title           String
  price           Int
  durationMinutes Int
  maxStudents     Int
  format          CoachSalesPackageFormat
  active          Boolean                 @default(true)
  sortOrder       Int                     @default(0)
  createdAt       DateTime                @default(now())
  updatedAt       DateTime                @default(now()) @updatedAt

  @@index([coachId, sortOrder])
}

model CoachStudentBooking {
  id              String                   @id @default(cuid())
  coach           Coach                    @relation(fields: [coachId], references: [id])
  coachId         String
  player          Player                   @relation(fields: [playerId], references: [id])
  playerId        String
  venue           Venue                    @relation(fields: [venueId], references: [id])
  venueId         String
  court           VenueCourt?              @relation(fields: [courtId], references: [id])
  courtId         String?
  startsAt        DateTime
  endsAt          DateTime
  courtPriceMinor Int?
  coachPriceMinor Int?
  status          CoachStudentBookingStatus @default(SCHEDULED)
  paymentStatus   CoachStudentBookingPaymentStatus @default(PENDING)
  note            String?
  createdAt       DateTime                 @default(now())
  updatedAt       DateTime                 @default(now()) @updatedAt

  @@index([coachId, startsAt])
  @@index([playerId, startsAt])
  @@index([venueId, startsAt])
  @@index([courtId, startsAt])
}

model OpenMatch {
  id        String   @id @default(cuid())
  venue     Venue    @relation(fields: [venueId], references: [id])
  venueId   String
  title     String
  startsAt  DateTime
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt
}

model Class {
  id        String   @id @default(cuid())
  venue     Venue    @relation(fields: [venueId], references: [id])
  venueId   String
  title     String
  startsAt  DateTime
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt
}

model ClassOffer {
  id              String           @id @default(cuid())
  coach           Coach            @relation(fields: [coachId], references: [id])
  coachId         String
  venue           Venue            @relation(fields: [venueId], references: [id])
  venueId         String
  title           String
  description     String?
  startsAt        DateTime
  durationMinutes Int?
  priceMinor      Int?
  currency        String?
  atcSportclubId  String?
  atcCourtId      String?
  status          ClassOfferStatus @default(ACTIVE)
  requests        ClassRequest[]
  createdAt       DateTime         @default(now())
  updatedAt       DateTime         @default(now()) @updatedAt

  @@index([status, startsAt])
  @@index([coachId, startsAt])
  @@index([venueId, startsAt])
}

model ClassRequest {
  id                       String                    @id @default(cuid())
  classOffer               ClassOffer                @relation(fields: [classOfferId], references: [id])
  classOfferId             String
  studentName              String
  studentEmail             String
  studentPhone             String?
  message                  String?
  selectionMode            ClassRequestSelectionMode @default(FLEXIBLE)
  selectedSlotStart        DateTime?
  selectedSlotDurationMinutes Int?
  selectedSlotPriceMinor   Int?
  selectedSlotCurrency     String?
  selectedAtcSportclubId   String?
  selectedAtcCourtId       String?
  status                   ClassRequestStatus        @default(NEW)
  createdAt                DateTime                  @default(now())
  updatedAt                DateTime                  @default(now()) @updatedAt

  @@index([classOfferId, createdAt])
  @@index([studentEmail, createdAt])
}

model Player {
  id             String               @id @default(cuid())
  name           String
  whatsappPhone  String?
  telegramHandle String?
  gender         PlayerGender?
  birthYear      Int?
  coachCategory  String?
  memberships    Membership[]
  coachBookings  CoachStudentBooking[]
  createdAt      DateTime             @default(now())
  updatedAt      DateTime             @default(now()) @updatedAt
}

model Membership {
  id        String   @id @default(cuid())
  player    Player   @relation(fields: [playerId], references: [id])
  playerId  String
  club      Club     @relation(fields: [clubId], references: [id])
  clubId    String
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt
}

[Assistant]
**Checking test protocols**

I need to inspect the tests and see if there’s an admin operations route available. I think the TCN documentation might have some relevant artifacts, particularly around test results. So, I’ll look for those findings in the protocol. It's a bit of a task, but I’m focusing on gathering the necessary information to ensure everything is covered correctly. Let's see what I can find!

{"id":"rs_01531f35460a6ac0016a0477f338288196953154b24f7c1c26","type":"reasoning","encrypted_content":"gAAAAABqBHf3vninVnNQMoXFfpJ-R1Iv2yZiJ41l5tEYBEvuqfi5X9bFOyLfG6dXIsfGbgRFHZM24doNmLMYpSKzG2v9HS4BLN7VLzmIdeJT-mTMB1MRwhyRZiwZIOYipXWGXq2BgpqbqeNHdTVsuFp3y9sBpibm-qoxWvqcZtwatAcq-kDDctBG4c4_W31OyE9rimOs8lOYLQmAC9u2DvnlneaOmdppSsSjeDRJEqNxGQn8A7XhiUOhFkoLfcyBVMhF0TlRm6bm0thh7aqIXdd9lnRDpb9pedCf79OM7_nD7DD_Cg3P0IOBdyb0Y5Xf3Y-ZVXnvAg_FTqBa-aVRCEoEsy-doyFJdwk1Z18PDXBKwa4QXRC4DujpaNiVfqJkMYIdiO-8QTpns3UBjvgCuhTr_SffcCUSGbG43xYpFERcW1yuLhX9jLfh5fectcWFystPBmONagxYSCkKIGn6W-Ct74Dg08eOwiFhn5NVCXnYwzs8Y4o56U6tI3riN8A1JzkEeNmyd0hvB6a_NZzZ5QcVwkOJzaH5ggKWF7QAolDay8hPL2Xsm03szLaU6AKr5QzJ4JpKNkheUtpgy8xHMyneGQwxFzx_DoKLhcM1N2TbmhLBxxOuqTLYmi4VLrPHK_ybq9Rvpu0x-gwoZx4M7k0bWC8HazOtnMXFuYAuuFofQ4-LvEQV71AcL9qUve2d7O9BYx84k3wXgCinBJKrLqoybBknvxM6GDMkAwdwaTqp4dmzgFK2jVSSiD4EgBBsyTLwMxAZpfBBlnJOuY2inl4yiklqgiPKqoTifDcaJ3qavaUbcHOp78p6RREQJzkph_B5kYGyahfwybyxka9p7ozH6W-_GKMNJbrc10iLV4UBFIY-e1RIffcghJJg5_Iel7qf67OCZuDTL7JyBtW_GD01SGPv_xD8ROrXuOWI5aqGc_CQ99NwZQB0xT4sraT4gt5zCGPGRbZ2LXaKVckbYAUstyKPXC7QWxnBiombph5tNdc8lYVjuAaEoPeyTyNqTeTjJsROPGnQDaPlgfMNofCwmAEn0U4zB18Ijxm5JVi4CM7ptYmYo5-9vI0YrdTEmu71d7J6RUCgoqpyNbRz_Mikp82QWBINZA==","summary":[{"type":"summary_text","text":"**Checking test protocols**\n\nI need to inspect the tests and see if there’s an admin operations route available. I think the TCN documentation might have some relevant artifacts, particularly around test results. So, I’ll look for those findings in the protocol. It's a bit of a task, but I’m focusing on gathering the necessary information to ensure everything is covered correctly. Let's see what I can find!"}]}

[toolResult] ENOENT: no such file or directory, access '/home/sebas/work/projects/ballbox/test-results/tcn-payment-protocol/findings-2026-04-23.md'

[toolResult]
# 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 admin QR generator should now be treated as an operator test/debug harness for Mercado Pago and webhook validation, not as the final buyer-facing vending-machine payment UX.
- 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 use the upstream order fetch plus canonical-reference mapping as 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`.
- For the current Ballbox Mercado Pago account/API surface, QR order create relies on the account-level webhook configuration instead of sending inline `notification_url` in the order payload, because Mercado Pago rejects that field on `POST /v1/orders` for this setup.
- 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.
- Real-world validation is now closed: one real public webhook and one real approved payment were persisted and verified in `/admin/payments` on 2026-05-13.
- Live MercadoPago payload quirks handled during closure: `topic_merchant_order_wh` remains intentionally ignored, `items[0].external_code` is accepted as product-id fallback, and `total_amount` is parsed from either string or number before persistence.
- Club revenue share is currently handled through scheduled manual settlements/bookkeeping for Ballbox-collected machines; no provider-native split payout path is live.
- First rollout planning now assumes one shared technical flow with two payment-owner modes: `BALLBOX` for club machines Ballbox collects on, and `TENANT` for client-owned collection setups such as Adidas rental machines; see `docs/payments-machine-rollout-rfc.md`.
- The admin surface includes a live QR generator preview for provider-level scan-and-pay validation, but production vending flow should move behind the TCN machine protocol instead of relying on that admin page.
- The webhook route is fetch-first: the webhook is just the trigger, and Ballbox verifies the order state by fetching Mercado Pago before persisting.
- TCN protocol review indicates the `additional hardware devices` warning applies to machine-side QR scanning, while the payment flow (`FunCode=8000`) returns `CodeUrl` for machine-screen QR display; see `docs/tcn-payments-integration-plan.md` and `test-results/tcn-payment-protocol/findings-2026-04-23.md`.
- 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 Result
- End-to-end payment validation succeeded against Ballbox native MercadoPago webhook configuration.
- Confirmed setup used:
  - Ballbox `BALLBOX_MERCADOPAGO_ACCESS_TOKEN`
  - Ballbox `BALLBOX_MERCADOPAGO_WEBHOOK_SECRET`
  - Ballbox `BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID`
  - public HTTPS webhook endpoint `/notification/mercadopago`
- Confirmed outcome:
  - a real payment was approved upstream
  - MercadoPago reached Ballbox webhook publicly
  - Ballbox fetched order details and recorded the approved payment locally
  - a `PaymentSession` row was persisted locally
  - the session was visible in `/admin/payments`

[toolResult]
# TCN payments execution plan

## Purpose
Ordered execution plan for getting from current Ballbox native Mercado Pago support to real TCN machine payments.

## Step 1. Finish provider-layer proof
- Goal: prove Ballbox native Mercado Pago flow works end to end without TCN hardware.
- Inputs:
  - `docs/implemented-payments.md`
  - `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
- Tasks:
  - validate public webhook reachability for `/notification/mercadopago`
  - run one real approved payment
  - confirm local `PaymentSession` persistence in `/admin/payments`
  - document exact payload and public URL evidence
- Exit criterion:
  - one real approved payment persists locally through webhook flow

## Step 2. Ask TCN the machine-contract questions
- Goal: close the remaining machine-side unknowns before coding the adapter.
- Inputs:
  - `docs/tcn-payments-integration-plan.md`
  - `docs/tcn-payments-open-questions.md`
  - `test-results/tcn-payment-protocol/findings-2026-04-23.md`
- Tasks:
  - send the question list to the TCN sales contact
  - ask them to confirm with engineers
  - capture answers in repo docs
- Exit criterion:
  - `CodeUrl` meaning and payment-success handshake are confirmed

## Step 3. Freeze Ballbox-TCN contract
- Goal: convert answers into a concrete adapter spec.
- Inputs:
  - `docs/tcn-payments-integration-plan.md`
  - answers captured from Step 2
- Tasks:
  - define request/response schema for TCN `8000`
  - define success/failure callback or polling behavior
  - define duplicate/idempotency behavior for repeated `TradeNo`
  - define machine-id mapping rules
- Exit criterion:
  - implementation spec is precise enough to code without guesswork

## Step 4. Implement adapter layer
- Goal: add the TCN-facing Ballbox endpoints over existing Mercado Pago primitives.
- Inputs:
  - concrete contract spec from Step 3
- Tasks:
  - add machine-facing endpoint(s)
  - map TCN requests to Ballbox/Mercado Pago order creation
  - map approved-payment state back into TCN success flow
  - persist any extra machine correlation fields needed
- Exit criterion:
  - local/manual adapter flow works without real machine hardware

## Step 5. Hardware validation when machines arrive
- Goal: verify actual stock TCN machine behavior.
- Tasks:
  - test real QR render on machine screen
  - test payment approval -> vend path
  - capture timing, retries, and failure cases
- Exit criterion:
  - one real vend succeeds through Ballbox-backed payment flow

## Step 6. Only then revisit payout automation
- Goal: avoid solving club payout distribution before the machine path is stable.
- Current default:
  - scheduled manual settlements / bookkeeping
- Related docs:
  - `docs/payments-club-settlement-strategy.md`
  - `PAY-4` in `docs/next-steps-queue.md`

[toolResult]
import { getPaymentsAdminDataFromDb } from "@/lib/payments";
import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";

export async function GET() {
  return paymentsJsonResponse({
    ok: true,
    item: await getPaymentsAdminDataFromDb(),
  });
}

[Assistant] cd /home/sebas/work/projects/ballbox && find . -path '*tcn*payment*protocol*' -o -path '*findings-2026-04-23.md' -o -path '*Protocol 2.5V*'

[toolResult] (no output)

[toolResult]
# 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.
- 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.
- That admin QR surface is now positioned as a provider test/debug harness; production vending-machine payments should integrate through the TCN machine protocol and let the machine render the buyer-facing QR.
- Club revenue share is currently handled with scheduled manual settlements/bookkeeping; Mercado Pago native split stays deferred until the machine flow and club count justify it.
- 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.
- `/classes` SSR no longer waits on ATC sportclub-summary hydration just to print club names, and `/classes-by-date` now parallelizes club/date ATC availability lookups behind a short shared server cache so first-byte time stays materially lower under production-like loads.
- 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 workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`: calendar shows the current week in an hour-by-hour matrix centered on the coach's real student classes, grouping simultaneous reservations into one class card, surfacing participant basics (name, sexo, edad, categoria), and marking paid vs pending reservations, and now starts with a large `Agregar clase` CTA that jumps into `Alumnos`; `Performance` shows per-club performance plus weekly earnings and student consistency/review rows, `Alumnos` keeps roster + taken-hour management with the student table visible by default and `Agregar clase` opening a wide inline composer instead of a right-side summary panel, and `Horarios y Clases` now auto-saves club selection, availability, packages, and Mercado Pago setup without explicit save buttons while no longer exposing the club's court-fee pricing to the coach; pending autosave writes are now also flushed if the user leaves the page before the debounce finishes.
- `/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.
- `/clubs/coaches` now lands on a club workspace with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: calendar now works one sede at a time, switches between `Disponibilidad` and `Reservas`, uses clickable tag filters for sede/day/club-owned coaches instead of dropdowns, assigns a persistent color to each coach-enabled court, and shows the selected day with all coach-enabled courts as columns so clubs can see coverage or reservation gaps per court without selecting a court filter; coaches assigned with `Asignar todas las canchas` are treated as flexible coverage and occupy one free court per half-hour, not every court at once; manual class loading and pending requests open from dedicated action buttons above the calendar, and manual class creation now requires selecting an active coach package so clubs cannot create classes for coaches without packages; analytics shows professor/reservation money splits and review placeholders, `Sedes` now starts from a large `Agregar sede` CTA, keeps venue creation behind that action, and lists every venue in an editable table with a deep edit action, and `Profesores` groups invite links plus the club coach table.
- The `/clubs/coaches` calendar cells now avoid repeating redundant court-color bars or per-slot coverage counts inside each block; court color remains visible in the column header and the empty/cubierto cell state carries the coverage signal.
- Club workspace clickable actions are now visually distinguished with stronger accent borders, CTA chips, and hover/focus states, while venue detail edits auto-save in place instead of requiring explicit `Guardar` buttons or reloading the user back to the top, and pending autosave writes now also flush on navigation/unmount so venue/price/schedule edits are not lost when switching screens quickly.
- Club roster reads now tolerate production databases that have not yet received the newest coach/student schema additions: missing `Coach.assignedCourtId`, `CoachStudentBooking`, player contact columns, or coach package tables degrade to empty/null data instead of turning `/api/clubs/coaches` into a 500.
- `/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.
- `/api/coaches/students` now also supports weekly recurring student bookings, and the coach workspace `Alumnos` section can create new students directly before assigning them to one-off or recurring classes.
- `/api/clubs/players` now lets a club create its own students and auto-membership them into that club before assigning them to a professor from the club workspace manual class flow.
- External-coach availability now excludes venue slots already absorbed by club-coach availability, so external coaches only see residual hours after club coaches take priority.
- `/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, including which fixed court each coach covers in that sede; the page is now structured as a visible step-by-step workspace with quick jump cards so coach hours and court selection are no longer hidden inside collapsed panels.
- `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.
- Isolated backend/helper tests for classes, network client helpers, players client helpers, sports backend modules, and TVs runtime invariants now live under `tests/unit`, while route/schema surfaces stay under `tests/contracts`.

## 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 workspace with `Calendar` / `Performance` / `Alumnos` / `Horarios y Clases`
- `/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.
- For the current Ballbox Mercado Pago account/API surface, QR order creation now relies on the account-level webhook configuration instead of sending inline `notification_url`, because Mercado Pago rejects that field on `POST /v1/orders` for this setup.
- 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 coach workspace split into `Calendar`, `Performance`, `Alumnos`, and `Horarios y Clases`, with the calendar rendered as a weekly class matrix for the coach's students and reservation payment status, `Performance` dedicated to club-level results and weekly analytics, and `Horarios y Clases` handling external clubs, weekly availability inside allowed venue/club windows, reusable class packages, and a Mercado Pago placeholder handle with per-section auto-save, but without showing venue fee/court-price internals to the coach.
- `/clubs/venues/[venueId]` now also supports recurring hourly price overrides on top of the venue base reservation price, and `/clubs/coaches` calendar cells now surface the effective slot price so clubs can see both coverage/reservations and what that half-hour costs.
- `/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 venue management now persists an optional assigned court per coach inside the venue: a specific court pins coverage to that court, while the default `Asignar todas las canchas` mode lets that coach float across enabled courts and be placed into one free court per half-hour in the club calendar.
- 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.
- Classes route/page callers now also import `lib/classes-backend.ts` directly for offer/request reads and writes, so class surfaces no longer depend on `lib/ballbox-store.ts` as their runtime entrypoint.
- 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.
- TVs admin/runtime route and page callers now import direct TVs backend modules plus shared TV types from `lib/tvs-backend-types.ts`, so `lib/ballbox-store.ts` is now a compatibility facade instead of the primary TVs entrypoint.
- 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-2` TVs runtime parity closure.
- Immediately after `OPS-2`: continue with `P1` dependency order.
- Then continue with `P1` in dependency order: `ARCH-2` and `ARCH-3` -> `PERF-1` -> `PAY-3`.
- Club revenue share is currently handled with scheduled manual settlements; `PAY-4` stays behind the TCN machine-facing adapter work as a future Mercado Pago split option.
- Keep detailed backlog/status only in `docs/next-steps-queue.md` to avoid drift.

## Ordered Payments Track
1. Provider-level Mercado Pago proof without TCN hardware is now closed.
   - result: Ballbox created a real production order, received the public webhook at `/notification/mercadopago`, fetched the approved order, and persisted the payment with visible reconciliation evidence in `/admin/payments`
   - notable fixes during closure: Mercado Pago order mapping now accepts `items[0].external_code` as product id fallback and parses `total_amount` from either string or number
   - docs: `docs/implemented-payments.md`, `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
2. After `OPS-1`, treat provider risk as materially reduced but not machine risk.
   - still required for machine confidence: real TCN `8000` request validation, `CodeUrl` rendering confirmation, approval-to-dispense handshake proof, duplicate/retry handling, timeout/expiry handling, and an operator runbook for approved-but-no-vend incidents
3. Ask TCN sales to confirm the remaining machine-contract questions with their engineers.
   - docs: `docs/tcn-payments-open-questions.md`, `docs/tcn-payments-integration-plan.md`
   - note: this is required because the machines are not here yet
4. Freeze the Ballbox <-> TCN adapter contract once those answers come back.
   - docs: `docs/tcn-payments-integration-plan.md`, `docs/tcn-payments-execution-plan.md`
5. Implement one machine payment flow with two owner modes.
   - `BALLBOX`: Ballbox-collected club machines
   - `TENANT`: tenant/client-collected machines such as Adidas rentals
   - backlog: `PAY-3` in `docs/next-steps-queue.md`
   - doc: `docs/payments-machine-rollout-rfc.md`
6. Use guided onboarding for the first wave of 5 machines.
   - first wave target: 3 `BALLBOX` machines + 2 `TENANT` machines
   - docs: `docs/payments-machine-rollout-rfc.md`, `docs/tcn-payments-execution-plan.md`
7. Keep club settlements informal/manual for Ballbox-collected machines until the machine path is stable and settlement pain is real.
   - backlog: `PAY-4` in `docs/next-steps-queue.md`
   - doc: `docs/payments-club-settlement-strategy.md`
8. Revisit automation later only if volume or ops pain justify it.
   - current future candidate: Mercado Pago split / marketplace

### Payments Track Docs
- Master rollout RFC: `docs/payments-machine-rollout-rfc.md`
- Technical implementation RFC: `docs/payments-machine-implementation-rfc.md`
- Implemented provider layer: `docs/implemented-payments.md`
- TCN direction and target flow: `docs/tcn-payments-integration-plan.md`
- Open external questions: `docs/tcn-payments-open-questions.md`
- Ordered execution sequence: `docs/tcn-payments-execution-plan.md`
- Club settlement strategy: `docs/payments-club-settlement-strategy.md`
- Current Mercado Pago webhook blocker/evidence: `docs/mercadopago-webhook-replay-notes-2026-04-23.md`
- TCN protocol evidence artifact: `test-results/tcn-payment-protocol/findings-2026-04-23.md`

### 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`.
- Production vending-machine payments should target a TCN adapter over that native MercadoPago layer; see `docs/tcn-payments-integration-plan.md`.
- 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`.
- Seed already run successfully against that DB.
- Current DB counts after seed were:
  - clubs: 2
  - venues: 3
  - vendingMachines: 4
  - tvDevices: 2
  - screens: 2
  - advertisers: 2
  - campaigns: 2
  - creatives: 3
  - assignments: 3
  - products: 2
  - players: 3
  - memberships: 4
  - coaches: 2
  - openMatches: 2
  - classes: 2
- New DB-backed read/write layer lives in `lib/ballbox-store.ts`.
- Bridge-era in-memory TVs modules were removed; seed fixtures now live under `prisma/seed-data.ts`.
- Routes already moved:
  - `/api/network`
  - `/api/network/[clubId]`
  - `/api/tvs/heartbeat`
  - `/api/tvs/impression`
  - `/api/tvs/manifest/[screenId]` via `lib/tvs-source.ts`
  - `/api/tvs/screens` and `/api/tvs/health` via `lib/tvs-source.ts`
- TVs persistence cleanup is complete; do not reintroduce in-memory compatibility layers.

[Assistant]
**Assessing artifacts and documentation**

I need to focus on identifying the exact artifacts to write or update, which might include recognizing areas where the code seams could be improved. There's a chance I should also check for any broken documentation references as these could reveal specification gaps that I can address now. Additionally, I want to inspect the next steps in the PAY-3 section to ensure everything remains aligned. Let's dive into that!

{"id":"rs_01531f35460a6ac0016a0477fe5cf08196afe018373ed50dba","type":"reasoning","encrypted_content":"gAAAAABqBHgBsQZPRt1tJagKgqj74xvWQCuQz5oPGxV2MnRomVN-aXalviZ2izkCgeBVuqQ-ceImFw0UMtbs7ac9iW8aGeA_fXwI56WyMpgXTxqoSbBXRYhI7q-zsHj4NanD8cCkHyCFojWgNPRQROr-6zgPXcIf2yNqvrokN1sVwW6Oo2NlFTtogrLkRXGZlfk5h-A6J08t8YVMlMwqVUxy0NqJVAc60ki7Y3FWIxawPltDXeWDqUd936ivguTk1TZmVa46wC3b3S6C4XxOfVCW5pWV05KizWeKdGQlpvJQbKqrnrxRZbsgf9j2uN-HNBSeDjisCvRivSjUtRGv_lLW2cTuQBBSgXYkR7RKukq3bVwjpXPEx6Dob1Is81dVGEmWig_dM3sVpQ8B3UJIgo8smLj4zxAIHbP5VGIIGH19VmR8Q_SDwUc70IY4TVDeAEhd1VydJaKfRwbSyBwRqc6ZDmnlY7AqBrnH78rWSEoq-7oMY_Nktc8jhMH237h8s5lTMJghNyDVnU3S9nDFzU4mq2MZJj_tlGdiXGNRtnW4RKqxxNRXdEt-nDgWQ68vsP8Q6BCN9npmBP6cs_MHsXm_KpMb2uB-hjOXiCnpkgnxYYgZ0iJneuq2qrvUsuo_mz8ilR1Aa-k83qCxdKGaSn_s_dBLDaS1zJoAwE5rjcHVrbNTpn-1SKRNJ3wYNRCvpJnUt8hM_CRREuesAmNbV9POvPjkiiF0luWK9GSLmAQlDlbr4xybRCAdhhat7MFs2y0_Mec9MmJLGs-DWel71wBz4Tsl5U417FFP_es43O5iPw-97ChNZL51wn7NHs1nYR1uP1DCjhj6cLQTozFTOrUTfcCcScNbiLN7YtgZra_3mDB4o2R6qAD6o3Q2dDc4W_A78BGXT2jUqmEndR394z8h73ypSIy80C0C5d5lrmpap87cv2UyvcdafRIjPuXODyoNeWbWatpLUS5HX9V94dEDhp7rW_mxO51-NSLCpXhm3nTb2VqPU3C1OBAUyeTyqrbOKiWW9E5xnkfWce5AMovLIVFh18t7-as-7mspDYeIkEFyFD8Tq8TnvxykoMlLWHyqvR00Zj2UUcKIqbny-bUI_1osh-Hojz51BYMnlAjAKg0qrn6H-vZ0qY-K4x-N4_kUg8BknKXHYbC3h5VOIyIdm520MfjjVw==","summary":[{"type":"summary_text","text":"**Assessing artifacts and documentation**\n\nI need to focus on identifying the exact artifacts to write or update, which might include recognizing areas where the code seams could be improved. There's a chance I should also check for any broken documentation references as these could reveal specification gaps that I can address now. Additionally, I want to inspect the next steps in the PAY-3 section to ensure everything remains aligned. Let's dive into that!"}]}

[toolResult]
  - 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 TCN vending-machine adapter over native MercadoPago
- Priority: P1
- Status: ready
- Depends on: OPS-1, PAY-2
- Scope:
  - define the Ballbox machine-facing adapter for TCN payment init (`FunCode=8000`) over the existing MercadoPago order-create route
  - map TCN `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`, and response `CodeUrl` to Ballbox-native payment fields
  - define the payment-success handshake back to TCN so approved payments can unlock dispense
  - support one shared machine payment flow with two payment-owner modes:
    - `BALLBOX` for club machines that Ballbox collects on
    - `TENANT` for customer-owned collection setups such as Adidas rental machines
  - keep `/admin/payments` positioned as an operator test/debug harness rather than the production vending UX
  - document the exact adapter contract and remaining TCN unknowns before implementation
- Notes:
  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
  - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
  - do not assume extra scanner hardware for the buyer flow; the open question is adapter contract detail, not QR display feasibility
  - club settlement automation can be revisited after the machine-facing flow is stable; manual bookkeeping is the current default
- Expected payoff: closes the actual machine integration path without fragmenting Ballbox collection vs tenant-direct collection into separate technical stacks

## P2 Sequenced Refactors

### PAY-4 Mercado Pago native split for club revenue share
- Priority: P2
- Status: blocked
- Depends on: OPS-1, PAY-2, PAY-3
- Scope:
  - keep scheduled manual settlements as the current operating default for Ballbox-collected club machines
  - document Mercado Pago native split as the only future automation path under consideration for club payouts

[35 more lines in file. Use offset=190 to continue.]

[Assistant]
**Inspecting VendingMachine artifacts**

I need to check if the VendingMachine has certain fields, looking at the contract spec docs, the Zod contract file, tests, and Prisma migration docs. The user wants me to identify which documents or code segments can be advanced without hardware. I'll focus on items like formalizing TCN init contract, owner/config contract, operation lifecycle, and webhook correlation. I should create concise bullet points with the artifact paths and the relevant content needed for updates.

Can advance now:

## Write new artifacts

1. `docs/tcn-payments-adapter-contract.md`
- Freeze Ballbox-side spec for TCN `FunCode=8000`.
- Include:
  - request field map: `MachineID`, `TradeNo`, `SlotNo`, `Price`, `NotityUrl`
  - Ballbox internal map: `vendingMachineId`, `machineTxnId`, `productId?`, `amountMinor`, `notifyUrl`
  - response shape with `CodeUrl`, `TradeNo`
  - duplicate `TradeNo` behavior
  - Ballbox error mapping for create failure / invalid machine / invalid price
  - explicit unknowns section: exact `CodeUrl` payload, exact approval->dispense callback shape

2. `docs/payments-machine-state-model.md`
- Spec lifecycle before hardware:
  - `CREATED -> APPROVED -> FAILED -> EXPIRED`
  - actor per transition: TCN init, Mercado Pago webhook, operator replay/retry
  - idempotency rules by `externalReference`, `providerOrderId`, `(machine, TradeNo)`

3. `docs/payment-owner-resolution-contract.md`
- Freeze non-UI contract for:
  - `BALLBOX` vs `TENANT`
  - `paymentOwnerKey`
  - `paymentExternalPosId`
  - config resolution rules for create + webhook fetch
  - fallback behavior if machine mapping missing

4. `docs/tcn-payments-test-matrix.md`
- Hardware-free matrix:
  - init success
  - duplicate `TradeNo`
  - webhook replay
  - mismatched machine/reference
  - order approved but no operation row
  - tenant-owner config fetch
  - minimum amount rejection
  - expired order handling

## Update existing artifacts

1. `docs/tcn-payments-integration-plan.md`
- Add concrete provisional field mapping table.
- Add Ballbox route naming decision.
- Add current assumptions vs external unknowns.

2. `docs/tcn-payments-open-questions.md`
- Tighten to only truly external questions.
- Move internal-decidable items out into spec docs above.

3. `docs/payments-machine-implementation-rfc.md`
- Convert from broad RFC to implementation-ready seams:
  - module boundaries
  - types
  - persistence contract
  - route/service/provider split

4. `docs/implemented-payments.md`
- Add explicit “current provider layer contract”.
- Add note that multi-owner + TCN adapter are next, not yet implemented.

5. `PLAN.md`
- Add sub-bullets under `PAY-3` for the above artifact outputs.
- Note broken evidence ref if still missing.

6. `docs/next-steps-queue.md`
- Expand `PAY-3` acceptance from generic to concrete doc deliverables.

## Likely code seams

1. `lib/contracts/payments.ts`
- Add machine-payment contracts:
  - `paymentOwnerModeSchema`
  - `paymentOperationStatusSchema`
  - TCN init request/response schemas
  - machine-payment create/result schemas

2. New `lib/contracts/tcn-payments.ts`
- Better if TCN shapes stay isolated from admin/provider shapes.

3. New `lib/payments-machine-flow.ts`
- Main seam for:
  - owner resolution
  - canonical reference creation/reuse
  - operation lifecycle
  - provider call orchestration

4. New `lib/payments-mercadopago-config.ts`
- Pull config resolution out of `lib/payments-mercadopago.ts`.

5. `lib/payments-mercadopago.ts`
- Refactor helpers to accept explicit config.
- Current hard blocker for `TENANT` mode.

6. `lib/payments.ts`
- Keep approved-session persistence here.
- Add operation helpers only if not split into dedicated module.

7. `app/api/integrations/payments/orders/route.ts`
- Repoint to shared machine-payment service.
- Keep as operator/test harness.

8. New `app/api/integrations/tcn/payments/init/route.ts`
- Thin adapter only.
- Parse TCN, call service, map response.

9. `app/notification/mercadopago/route.ts`
- Needs future seam for `providerOrderId -> PaymentOperation -> owner config`.

10. `prisma/schema.prisma`
- Ready spec artifact now:
  - machine owner fields on `VendingMachine`
  - new `PaymentOperation` model
- Can define migration contract now even without hardware.

## Gaps found

- `test-results/tcn-payment-protocol/findings-2026-04-23.md` is referenced in docs but not present in repo.
- That should be restored or references removed now. Big doc drift risk.

Best immediate package:
- write 3 new docs:
  - `docs/tcn-payments-adapter-contract.md`
  - `docs/payments-machine-state-model.md`
  - `docs/payment-owner-resolution-contract.md`
- update 5 existing docs:
  - `docs/tcn-payments-integration-plan.md`
  - `docs/tcn-payments-open-questions.md`
  - `docs/payments-machine-implementation-rfc.md`
  - `docs/implemented-payments.md`
  - `PLAN.md`

If you want, I can draft those exact docs next.