Yes. That secret is for the old bridge-style approved callback.

Short version:
- `BALLBOX_PAYMENTS_SHARED_SECRET` protects `/api/integrations/payments/approved`
- that route exists for legacy compatibility
- the new native path is MercadoPago → `/notification/mercadopago`
- for pure native flow, that shared secret should not be needed

So your read is right:
- if payments are fully migrated, `BALLBOX_PAYMENTS_SHARED_SECRET` should stop mattering operationally
- right now the repo/docs still say “legacy callback compatibility remains”
- that means migration is not fully closed in code/docs yet, even if native flow already works

What I see in repo:
- native webhook exists and persists approved sessions after fetching the MP order
- native QR order creation exists
- health endpoint still reports legacy callback info
- docs still say bridge compatibility remains during transition
- queue still has `PAY-2 Full payments migration into Ballbox host app` after `OPS-1`

So the contradiction is:
- functionally, most of the path is already in Ballbox
- operationally/docs, repo still treats legacy callback as tolerated fallback
- therefore “fully migrated” is not yet declared done

Practical rule:
- for `OPS-1` native validation, ignore `BALLBOX_PAYMENTS_SHARED_SECRET`
- for `PAY-2`, we should remove or demote the legacy callback path once we confirm nothing still depends on it

Recommended next step:
- validate only the native flow now:
  1. health endpoint
  2. create QR order via Ballbox
  3. pay it
  4. confirm `/notification/mercadopago` persists session
  5. verify `/admin/payments`
- after that, do `PAY-2`: remove remaining bridge assumptions and update docs

If you want, I’ll trace the remaining legacy dependency surface now and tell you whether `PAY-2` is just doc cleanup or still has real code to move.


lets face pay-2 first to ensure ops-1 flows smoothly


bash

git status --short --branch && printf '\n---LEGACY CALLBACK SEARCH---\n' && grep -Rni "BALLBOX_PAYMENTS_URL\|BALLBOX_PAYMENTS_SHARED_SECRET\|PAYMENTS_CALLBACK_TOKEN\|/api/integrations/payments/approved\|legacy callback\|bridge" app lib docs tests package.json prisma | head -n 300


# Repo Map

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

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

## Product Domains

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

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

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

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

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

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

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


## ops2-runtime-parity...origin/ops2-runtime-parity

---LEGACY CALLBACK SEARCH---
app/api/admin/summary/route.ts:42:        callbackPath: "/api/integrations/payments/approved",
app/api/integrations/payments/approved/route.ts:25:    recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
app/api/integrations/payments/health/route.ts:17:    callbackUrl: "/api/integrations/payments/approved",
app/api/integrations/payments/health/route.ts:19:    recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
app/api/integrations/payments/sessions/route.ts:19:    path: "/api/integrations/payments/approved",
app/api/integrations/payments/sessions/route.ts:22:    recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
lib/payments-auth.ts:6:      envVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
lib/payments-auth.ts:7:      value: process.env.BALLBOX_PAYMENTS_SHARED_SECRET,
lib/payments-auth.ts:10:      envVar: "BALLBOX_PAYMENTS_CALLBACK_TOKEN",
lib/payments-auth.ts:11:      value: process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN,
docs/atc-mock-integration-plan.md:9:- Treat mocks as a bridge toward a likely `read+write` ATC integration, not as justification for a permanently read-only product.
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/next-steps-queue.md:126:  - migrate remaining bridge-owned MercadoPago flow (webhook + order lifecycle) into Ballbox routes/lib
docs/next-steps-queue.md:127:  - remove reliance on `BALLBOX_PAYMENTS_URL` bridge dependency for operational status
docs/next-steps-queue.md:128:  - keep callback compatibility during transition, then retire bridge dependency cleanly
docs/implemented-host-app.md:22:- Ballbox accepts approved payment callbacks at `/api/integrations/payments/approved`.
docs/implemented-tvs.md:7:- No Streetcast compatibility layer or in-memory TVs bridge remains in runtime code.
docs/implemented-payments.md:16:- `/api/integrations/payments/approved`
docs/implemented-payments.md:28:- Canonical env var is `BALLBOX_PAYMENTS_SHARED_SECRET`; `BALLBOX_PAYMENTS_CALLBACK_TOKEN` remains accepted as fallback compatibility.
docs/implemented-payments.md:33:- Legacy bridge callback compatibility remains at `/api/integrations/payments/approved`.
docs/implemented-payments.md:36:- If neither `BALLBOX_PAYMENTS_SHARED_SECRET` nor legacy `BALLBOX_PAYMENTS_CALLBACK_TOKEN` is set in Ballbox, `/api/integrations/payments/approved` returns `500 PAYMENTS_SHARED_SECRET_NOT_CONFIGURED`.
docs/implemented-payments.md:37:- The callback payload is the approved-session handoff from the payment bridge into Ballbox.
docs/implemented-payments.md:52:  - Ballbox `BALLBOX_PAYMENTS_SHARED_SECRET` (legacy callback compatibility path)
tests/contracts/payments/auth.test.ts:33:    delete process.env.BALLBOX_PAYMENTS_SHARED_SECRET;
tests/contracts/payments/auth.test.ts:34:    delete process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN;
tests/contracts/payments/auth.test.ts:38:    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";
tests/contracts/payments/auth.test.ts:44:    const { POST } = await import("@/app/api/integrations/payments/approved/route");
tests/contracts/payments/auth.test.ts:46:      new NextRequest("http://localhost/api/integrations/payments/approved", {
tests/contracts/payments/auth.test.ts:72:  it("keeps legacy callback token as compatibility fallback", async () => {
tests/contracts/payments/auth.test.ts:73:    process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN = "legacy-secret";
tests/contracts/payments/auth.test.ts:79:    const { POST } = await import("@/app/api/integrations/payments/approved/route");
tests/contracts/payments/auth.test.ts:81:      new NextRequest("http://localhost/api/integrations/payments/approved", {
tests/contracts/payments/auth.test.ts:108:    const { POST } = await import("@/app/api/integrations/payments/approved/route");
tests/contracts/payments/auth.test.ts:110:      new NextRequest("http://localhost/api/integrations/payments/approved", {
tests/contracts/payments/auth.test.ts:136:      sharedSecretEnvVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
tests/contracts/payments/auth.test.ts:138:        "BALLBOX_PAYMENTS_SHARED_SECRET",
tests/contracts/payments/auth.test.ts:139:        "BALLBOX_PAYMENTS_CALLBACK_TOKEN",
tests/contracts/payments/auth.test.ts:146:    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";
tests/contracts/payments/auth.test.ts:148:    const { POST } = await import("@/app/api/integrations/payments/approved/route");
tests/contracts/payments/auth.test.ts:150:      new NextRequest("http://localhost/api/integrations/payments/approved", {
tests/contracts/payments/auth.test.ts:177:    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";
tests/contracts/payments/auth.test.ts:198:    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";
tests/contracts/payments/auth.test.ts:200:    const { POST } = await import("@/app/api/integrations/payments/approved/route");
tests/contracts/payments/auth.test.ts:202:      new NextRequest("http://localhost/api/integrations/payments/approved", {


import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPublicAppUrl } from "@/lib/public-url";
import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";

export async function GET() {
  const items = await listRecentApprovedPayments();
  const publicAppUrl = getPublicAppUrl();
  const sharedSecret = getPaymentsSharedSecretConfig();
  const mercadoPago = getMercadoPagoConfig();

  return paymentsJsonResponse({
    ok: true,
    integration: "payments",
    targetService: "ballbox host app",
    mode: "internal",
    configured: true,
    path: "/api/integrations/payments/approved",
    orderCreatePath: "/api/integrations/payments/orders",
    mercadopagoWebhookPath: "/notification/mercadopago",
    recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
    sharedSecretConfigured: sharedSecret.configured,
    sharedSecretEnvVar: sharedSecret.envVar,
    mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
    mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
    items: items.map(serializePaymentSession),
  });
}


import { NextResponse } from "next/server";
import { getAdminOverviewMetricsFromDb } from "@/lib/admin-overview";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
import { canonicalEntities, productAreas, runtimeSurface } from "@/lib/platform";

export async function GET() {
  const mercadoPago = getMercadoPagoConfig();
  const paymentsSharedSecret = getPaymentsSharedSecretConfig();
  const metrics = await getAdminOverviewMetricsFromDb();

  return NextResponse.json({
    ok: true,
    app: "ballbox-host",
    surfaces: {
      marketing: "/",
      admin: "/admin",
      runtime: runtimeSurface.href,
    },
    products: productAreas,
    canonicalEntities,
    modules: {
      network: {
        clubs: metrics.network.clubs,
        venues: metrics.network.venues,
        vendingMachines: metrics.network.machines,
      },
      players: {
        players: metrics.players.players,
        memberships: metrics.players.memberships,
      },
      sports: {
        coaches: metrics.sports.coaches,
        openMatches: metrics.sports.openMatches,
        classes: metrics.sports.classes,
      },
    },
    integrations: {
      payments: {
        configured: true,
        targetService: "ballbox host app",
        callbackPath: "/api/integrations/payments/approved",
        mercadopagoWebhookPath: "/notification/mercadopago",
        sharedSecretConfigured: paymentsSharedSecret.configured,
        sharedSecretEnvVar: paymentsSharedSecret.envVar,
        acceptedSharedSecretEnvVars: paymentsSharedSecret.acceptedEnvVars,
        mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
        mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
      },
      tvs: {
        configured: true,
        mode: "internal-module",
        targetService: "ballbox host app",
      },
    },
  });
}


import { hasValidBearerAuthHeader, resolveSharedSecret } from "@/lib/shared-secret";

export function getPaymentsSharedSecretConfig() {
  return resolveSharedSecret([
    {
      envVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
      value: process.env.BALLBOX_PAYMENTS_SHARED_SECRET,
    },
    {
      envVar: "BALLBOX_PAYMENTS_CALLBACK_TOKEN",
      value: process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN,
    },
  ]);
}

export function isAuthorizedPaymentsIntegrationRequest(authorizationHeader: string | null) {
  return hasValidBearerAuthHeader(authorizationHeader, getPaymentsSharedSecretConfig().value);
}

export function getPaymentsIntegrationRequestAuthResult(authorizationHeader: string | null) {
  const sharedSecret = getPaymentsSharedSecretConfig();

  if (!sharedSecret.configured) {
    return {
      ok: false,
      error: "PAYMENTS_SHARED_SECRET_NOT_CONFIGURED" as const,
      envVar: sharedSecret.envVar,
      acceptedEnvVars: sharedSecret.acceptedEnvVars,
    };
  }

  if (!hasValidBearerAuthHeader(authorizationHeader, sharedSecret.value)) {
    return {
      ok: false,
      error: "UNAUTHORIZED" as const,
    };
  }

  return {
    ok: true,
  };
}


import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { createPaymentsAdminErrorResponse } from "@/lib/contracts/payments";

const recordApprovedPayment = vi.fn();
const listRecentApprovedPayments = vi.fn();
const serializePaymentSession = vi.fn((value) => value);
const getPaymentReferenceCorrelation = vi.fn((value) => {
  const [prefix, vendingMachineId, machineTxnId, ...rest] = value.externalReference.split(":");
  const parsedReference =
    prefix === "payment" && vendingMachineId && machineTxnId && rest.length === 0
      ? { prefix, vendingMachineId, machineTxnId }
      : null;

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

vi.mock("@/lib/payments", () => ({
  getPaymentReferenceCorrelation,
  recordApprovedPayment,
  listRecentApprovedPayments,
  serializePaymentSession,
}));

describe("payments shared-secret auth", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    delete process.env.BALLBOX_PAYMENTS_SHARED_SECRET;
    delete process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN;
  });

  it("accepts canonical shared secret for approved callback route", async () => {
    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";
    recordApprovedPayment.mockResolvedValue({
      id: "session_001",
      approvedAt: new Date("2026-03-20T18:00:00.000Z"),
    });

    const { POST } = await import("@/app/api/integrations/payments/approved/route");
    const response = await POST(
      new NextRequest("http://localhost/api/integrations/payments/approved", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          authorization: "Bearer shared-secret",
        },
        body: JSON.stringify({
          sessionId: "session_001",
          vendingMachineId: "machine_001",
          productId: "product_001",
          machineTxnId: "txn_001",
          externalReference: "payment:machine_001:txn_001",
          amountMinor: 1000,
          currency: "ARS",
          status: "approved",
          approvedAt: "2026-03-20T18:00:00.000Z",
          lateApproval: false,
          providerPaymentId: "provider_001",
        }),
      })
    );

    expect(response.status).toBe(200);
    expect(recordApprovedPayment).toHaveBeenCalledTimes(1);
  });

  it("keeps legacy callback token as compatibility fallback", async () => {
    process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN = "legacy-secret";
    recordApprovedPayment.mockResolvedValue({
      id: "session_001",
      approvedAt: new Date("2026-03-20T18:00:00.000Z"),
    });

    const { POST } = await import("@/app/api/integrations/payments/approved/route");
    const response = await POST(
      new NextRequest("http://localhost/api/integrations/payments/approved", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          authorization: "Bearer legacy-secret",
        },
        body: JSON.stringify({
          sessionId: "session_001",
          vendingMachineId: "machine_001",
          productId: "product_001",
          machineTxnId: "txn_001",
          externalReference: "payment:machine_001:txn_001",
          amountMinor: 1000,
          currency: "ARS",
          status: "approved",
          approvedAt: "2026-03-20T18:00:00.000Z",
          lateApproval: false,
          providerPaymentId: "provider_001",
        }),
      })
    );

    expect(response.status).toBe(200);
    expect(recordApprovedPayment).toHaveBeenCalledTimes(1);
  });

  it("fails closed with explicit misconfiguration when no payments secret is set", async () => {
    const { POST } = await import("@/app/api/integrations/payments/approved/route");
    const response = await POST(
      new NextRequest("http://localhost/api/integrations/payments/approved", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          sessionId: "session_001",
          vendingMachineId: "machine_001",
          productId: "product_001",
          machineTxnId: "txn_001",
          externalReference: "payment:machine_001:txn_001",
          amountMinor: 1000,
          currency: "ARS",
          status: "approved",
          approvedAt: "2026-03-20T18:00:00.000Z",
          lateApproval: false,
          providerPaymentId: "provider_001",
        }),
      })
    );
    const payload = await response.json();

    expect(response.status).toBe(500);
    expect(payload).toEqual({
      ok: false,
      error: "PAYMENTS_SHARED_SECRET_NOT_CONFIGURED",
      sharedSecretEnvVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
      acceptedSharedSecretEnvVars: [
        "BALLBOX_PAYMENTS_SHARED_SECRET",
        "BALLBOX_PAYMENTS_CALLBACK_TOKEN",
      ],
    });
    expect(recordApprovedPayment).not.toHaveBeenCalled();
  });

  it("rejects approved callback requests with the wrong secret", async () => {
    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";

    const { POST } = await import("@/app/api/integrations/payments/approved/route");
    const response = await POST(
      new NextRequest("http://localhost/api/integrations/payments/approved", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          authorization: "Bearer wrong-secret",
        },
        body: JSON.stringify({
          sessionId: "session_001",
          vendingMachineId: "machine_001",
          productId: "product_001",
          machineTxnId: "txn_001",
          externalReference: "payment:machine_001:txn_001",
          amountMinor: 1000,
          currency: "ARS",
          status: "approved",
          approvedAt: "2026-03-20T18:00:00.000Z",
          lateApproval: false,
          providerPaymentId: "provider_001",
        }),
      })
    );

    expect(response.status).toBe(401);
    expect(recordApprovedPayment).not.toHaveBeenCalled();
  });

  it("reports internal health status and mercadopago config flags", async () => {
    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";
    process.env.BALLBOX_MERCADOPAGO_ACCESS_TOKEN = "mp-token";
    process.env.BALLBOX_MERCADOPAGO_WEBHOOK_SECRET = "mp-webhook";

    const { GET } = await import("@/app/api/integrations/payments/health/route");
    const response = await GET();
    const payload = await response.json();

    expect(response.status).toBe(200);
    expect(payload).toMatchObject({
      ok: true,
      mode: "internal-module",
      sharedSecretConfigured: true,
      mercadopago: {
        accessTokenConfigured: true,
        webhookSecretConfigured: true,
      },
    });
  });

  it("rejects approved callback payloads with non-canonical payment correlation", async () => {
    process.env.BALLBOX_PAYMENTS_SHARED_SECRET = "shared-secret";

    const { POST } = await import("@/app/api/integrations/payments/approved/route");
    const response = await POST(
      new NextRequest("http://localhost/api/integrations/payments/approved", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          authorization: "Bearer shared-secret",
        },
        body: JSON.stringify({
          sessionId: "session_001",
          vendingMachineId: "machine_001",
          productId: "product_001",
          machineTxnId: "txn_001",
          externalReference: "payment:machine_999:txn_001",
          amountMinor: 1000,
          currency: "ARS",
          status: "approved",
          approvedAt: "2026-03-20T18:00:00.000Z",
          lateApproval: false,
          providerPaymentId: "provider_001",
        }),
      })
    );
    const payload = await response.json();

    expect(response.status).toBe(400);
    expect(payload).toEqual(
      createPaymentsAdminErrorResponse("INVALID_PAYLOAD", 400, {
        externalReference: "payment:machine_999:txn_001",
        correlation: {
          parsedReference: {
            prefix: "payment",
            vendingMachineId: "machine_999",
            machineTxnId: "txn_001",
          },
          externalReferenceIsCanonical: true,
          vendingMachineIdMatchesReference: false,
          machineTxnIdMatchesReference: true,
        },
      })
    );
    expect(recordApprovedPayment).not.toHaveBeenCalled();
  });
});


grep -Rni "getPaymentsSharedSecretConfig\|getPaymentsIntegrationRequestAuthResult\|isAuthorizedPaymentsIntegrationRequest" app lib tests | head -n 200


app/api/admin/summary/route.ts:4:import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
app/api/admin/summary/route.ts:9:  const paymentsSharedSecret = getPaymentsSharedSecretConfig();
app/api/integrations/payments/approved/route.ts:9:import { getPaymentsIntegrationRequestAuthResult, getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
app/api/integrations/payments/approved/route.ts:21:  const sharedSecret = getPaymentsSharedSecretConfig();
app/api/integrations/payments/approved/route.ts:33:  const auth = getPaymentsIntegrationRequestAuthResult(request.headers.get("authorization"));
app/api/integrations/payments/health/route.ts:1:import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
app/api/integrations/payments/health/route.ts:8:  const sharedSecret = getPaymentsSharedSecretConfig();
app/api/integrations/payments/sessions/route.ts:2:import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
app/api/integrations/payments/sessions/route.ts:10:  const sharedSecret = getPaymentsSharedSecretConfig();
lib/payments-auth.ts:3:export function getPaymentsSharedSecretConfig() {
lib/payments-auth.ts:16:export function isAuthorizedPaymentsIntegrationRequest(authorizationHeader: string | null) {
lib/payments-auth.ts:17:  return hasValidBearerAuthHeader(authorizationHeader, getPaymentsSharedSecretConfig().value);
lib/payments-auth.ts:20:export function getPaymentsIntegrationRequestAuthResult(authorizationHeader: string | null) {
lib/payments-auth.ts:21:  const sharedSecret = getPaymentsSharedSecretConfig();


# Host App Implemented State

## What Exists
- `ballbox` is the host app.
- `/` stays as the marketing home.
- `/admin` exists as the shared admin shell.
- `/classes` exists as a separate public Ballbox surface.
- `/classes-by-date` exists as a second public Ballbox surface for zone-first class discovery.
- The public home navigation now surfaces `Reservas de cancha` and `Sign in` directly instead of exposing coach/admin labels in the marketing navbar.
- `/classes/me` exists as a lightweight student follow-up view for submitted class requests.
- Product-separated admin routes exist:
  - `/admin/tvs`
  - `/admin/payments`
  - `/admin/network`
  - `/admin/sports`
  - `/admin/players`

## Implemented Backend Foundation
- Ballbox exposes admin/core APIs under `app/api`.
- `/admin` can be protected with a simple shared-password session via `BALLBOX_ADMIN_PASSWORD`.
- Payments integration endpoints exist as Ballbox-side BFF/proxy routes.
- Ballbox accepts approved payment callbacks at `/api/integrations/payments/approved`.
- Current payments server-to-server handoff can be protected with a shared bearer secret on both inbound and outbound calls.
- Approved payment callbacks are now validated against canonical `payment:{vendingMachineId}:{machineTxnId}` references before persistence.
- Ballbox exposes admin payments operations under `/api/admin/payments/*`.
- Ballbox exposes shared network operations under `/api/network*`.
- 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`.
- TVs endpoints exist under `/api/tvs/*`.
- Ballbox owns internal venues/screens/campaigns data and persists TVs in Ballbox Postgres; clubs are ATC-sourced and stored as `atcSportclubId` links only.
- Seed/bootstrap fixtures for TVs live under `prisma/`, not runtime `lib/` modules.
- Local development now also ships with a tracked Docker Postgres setup plus `.env.local` defaults, so Ballbox can run against `ballbox_local` without depending on the remote database.
- Repo quality scripts exist for `lint`, `typecheck`, `test:contracts`, `test:e2e`, and `build`.
- Playwright now runs with deterministic DB cleanup before and after the e2e suite for `PW E2E` fixtures.

## Surface Status
- TVs runtime/admin live inside Ballbox.
- TVs player delivery is a special-case host-app surface: `/player/[screenId]` is served from Ballbox but intentionally bypasses the Next client/React runtime in favor of plain HTML + JavaScript for constrained devices.
- Payments remain external and are represented in Ballbox through integration routes.
- Payments approved sessions now also have local admin CRUD/reconciliation inside Ballbox.
- Network now has DB-backed admin CRUD for ATC-linked clubs, venues, and vending machines.
- Current admin-only routes use simple shared access, not product-level RBAC.
- Players surface now has a DB-backed read API for `Player` and `Membership`; admin CRUD is live and activity/participation models are still pending.
- Sports now has DB-backed read/admin APIs for coaches, open matches, and classes; ATC/player flows are still pending.
- Sports now also has a first student-facing `/classes` MVP over Ballbox-local offers/requests with resilient ATC public-read enrichment.
- `/classes` now paints Ballbox-local offer data first and resolves ATC public-read context after load through cached host-app APIs, so ATC latency does not block first paint.
- `/classes-by-date` now behaves as the public reservation entry point, asking for a zone first and then narrowing visible club availability from there.
- `/clubs` now behaves as the club-only service home after authentication, presenting the club's operational modules as service cards before drilling into a specific service.
- `/clubs/coaches` now behaves as a club operations surface with top-level `Calendar`, `Analytics`, `Sedes`, and `Profesores` sections: clubs can review the current week's coach availability/reservations, inspect professor/reservation money splits, manage venue quick settings, and work the coach roster from the same navbar.
- Club workspace settings that edit existing venue/reservation data now auto-save in place, and clickable cards/links use explicit accent styling so they stand apart from read-only panels.
- `/admin` overview now derives its cards from lightweight aggregate DB counts instead of hydrating full module datasets during SSR.
- 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.


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"),
  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 } : {}),
  });
}


# Next Steps Queue

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

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

## Recently Completed (2026-04-14)

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

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

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

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

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

## P0 Ready

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

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

## P1 After P0

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

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

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

### PAY-2 Full payments migration into Ballbox host app
- Priority: P1
- Status: ready
- Depends on: OPS-1
- Scope:
  - migrate remaining bridge-owned MercadoPago flow (webhook + order lifecycle) into Ballbox routes/lib
  - remove reliance on `BALLBOX_PAYMENTS_URL` bridge dependency for operational status
  - keep callback compatibility during transition, then retire bridge dependency cleanly
- Progress (2026-04-14): host app now includes MercadoPago webhook route (`/notification/mercadopago`) plus signature verification and order fetch primitives.
- Progress (2026-04-14): host app now also includes QR order creation (`/api/integrations/payments/orders`) with canonical `externalReference` generation (`payment:{vendingMachineId}:{machineTxnId}`).
- Expected payoff: one-repo payments ownership, simpler operations, faster debugging

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

## P2 Sequenced Refactors

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

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

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


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

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

## Backend Split
- Ballbox Next.js host:
  - app shell
  - admin UI
  - shared auth/RBAC foundation
  - low-complexity CRUD
  - BFF aggregation and integration routes
  - shared `network` core entities
  - internal TVs module and runtime
- External specialized services:
  - none required for current payments callback/webhook flow (legacy bridge compatibility remains temporarily accepted)

## 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 status plus MercadoPago configuration/webhook metadata.
- Payments approved-session callbacks now land in Ballbox, validate canonical payment correlation, and persist as `PaymentSession` records.
- Payments now have an admin CRUD/reconciliation surface over local `PaymentSession` rows via `/api/admin/payments/*`.
- Final target model is documented in `docs/domain-model-target.md`; end state does not keep retro-compatibility shims.
- ESLint is now wired and the repo exposes an explicit `typecheck` script.
- `/admin` and current admin-only APIs can now be gated by a shared password cookie session.
- `/admin/tvs` now performs admin CRUD for screens and tv devices through admin-only APIs under `/api/admin/tvs/*`.
- `/admin/tvs` now performs admin CRUD for campaigns, creatives, and assignments through admin-only APIs under `/api/admin/tvs/*`.
- TVs impression writes now validate creative-to-screen activity windows before persisting telemetry.
- Players now have DB-backed read/admin APIs for `Player` and `Membership`.
- Players route + page callers now import `lib/players-backend.ts` (and `lib/network-backend-clubs.ts` for club lists) directly instead of relying on `lib/ballbox-store.ts` for runtime access.
- Sports now have DB-backed read/admin APIs for `Coach`, `OpenMatch`, and `Class`.
- Classes MVP now also has DB-backed public APIs for `ClassOffer` and `ClassRequest`.
- `/classes/me` now provides a lightweight student request-history surface over Ballbox-local `ClassRequest` records (read-only, demo-grade email lookup).
- `ClassRequest` now snapshots the student's selected visible slot when they choose one, and otherwise persists an explicit flexible-selection fallback mode.
- Coach availability now ships as weekly ranges with 90-minute slots, filtered by coach club selection and ATC club availability.
- Coach and club account entry now starts at `/coaches`, with `Sign in` first and `Sign up` only for users without an account.
- Coaches and clubs now sign in with per-account email + password credentials, and `/api/account/session` routes each session to the correct dashboard.
- Coach sign-up now asks only for name, email, and password; if a club invited that coach, the invite link auto-links the account to that club.
- `/coaches/settings` now opens on a dashboard split into `Calendar` and `Analytics`, then lets coaches move into configuration from the shared navbar to choose external clubs, edit weekly availability only inside allowed venue/club windows, configure reusable class packages, and save a Mercado Pago placeholder handle.
- `/coaches/settings` now also lets coaches attach existing Ballbox players to concrete booked slots in their active venues, so taken hours, weekly earnings, consistency tracking, and lightweight student follow-up live inside the coach workspace.
- Club sign-in/sign-up now lands on `/clubs`, a service home that separates the club's operational modules before entering the detailed coaches workspace.
- Club accounts now have a dedicated workspace at `/clubs/coaches` split into `Calendar`, `Analytics`, `Sedes`, and `Profesores`, while venue-by-venue deep setup still lives at `/clubs/venues/[venueId]`, all under the same shared navbar/profile chrome.
- Venue setup now uses two simple fields for opening and closing time plus a collapsible weekly half-hour grid for coach hours, shows closed periods in red while editing coach availability, lets clubs mark whether external coaches are allowed, keeps coach-enabled courts in a dedicated selection panel, and includes reservation pricing/cancellation settings both in the venue detail and as quick settings on `/clubs/coaches`, including a discount that applies only to coaches assigned to that club.
- ATC lookups for class surfaces now resolve by club (no court coupling), and class request validation rechecks the selected slot date/time against ATC public-read.
- `/classes` SSR now avoids blocking first paint on ATC public-read fetches; the page paints local offer data first, then hydrates ATC availability from cached API responses.
- `/admin` overview SSR now uses direct aggregate counts instead of loading full admin datasets only to derive metrics.
- Sports/classes facade exports in `lib/ballbox-store.ts` now delegate to dedicated sports-events and classes backend modules instead of keeping that slice inline.
- Sports route + page callers now import `lib/sports-backend-events.ts` and `lib/sports-backend-coaches.ts` directly, so `lib/ballbox-store.ts` is no longer the primary sports entrypoint.
- TV runtime/backend facade exports in `lib/ballbox-store.ts` now delegate to `lib/tvs-backend-runtime.ts` instead of keeping source/manifest/heartbeat/impression logic inline.
- UI color/system tokens are split between landing (`app/styles/landing-tokens.css`) and admin (`app/styles/admin-tokens.css`).

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

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

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

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

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

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

## Acceptance Criteria
- Landing still works at `/`.
- `/admin` exists and links to product-specific areas.
- `/admin/tvs`, `/admin/payments`, `/admin/network`, `/admin/sports`, `/admin/players`, and `/admin/screens` render in Ballbox.
- `app/api` contains a host-app BFF foundation for admin and integrations.
- TVs no longer depend on Streetcast or hardcoded ownership data; Ballbox exposes internal `/api/tvs/*` backed by Ballbox Postgres.
- Ballbox exposes the concrete per-screen TV runtime under `/player/[screenId]`.
- TVs migration is not complete until the Streetcast parity checklist is reviewed and every legacy runtime feature is marked as covered, intentionally dropped, or replaced.
- TVs runtime must support local cached playback for video creatives instead of relying only on remote streaming.
- Ballbox accepts approved payment callbacks under `/api/integrations/payments/approved` and persists them locally.
- 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.


# Payments Implemented State

## Current Split
- Ballbox owns the admin surface and the approved-payment callback receiver.
- Ballbox now also owns a first-party MercadoPago webhook intake route at `/notification/mercadopago` with signature validation and order fetch.
- `ballbox-payment-integration` still owns QR creation and part of order lifecycle while host-app migration continues.
- `/api/integrations/payments/sessions` lists Ballbox-local approved callback records, not the full upstream payment lifecycle.
- `/admin/payments` now manages local approved payment sessions directly for reconciliation/correction.

## 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/approved`
- `/api/integrations/payments/orders`
- `/notification/mercadopago`

## Persistence
- Ballbox persists approved payment callbacks as `PaymentSession` rows in Ballbox Postgres.
- 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 integration auth now uses a shared bearer secret for inbound approved callbacks.
- MercadoPago webhook intake now validates `x-signature` + `x-request-id` against a local webhook secret before fetching order details.
- Ballbox now fails closed on the approved callback when no payments shared secret is configured and returns an explicit misconfiguration error instead of accepting the write.
- Ballbox now validates inbound approved callbacks against the canonical `payment:{vendingMachineId}:{machineTxnId}` external reference before persisting.
- Canonical env var is `BALLBOX_PAYMENTS_SHARED_SECRET`; `BALLBOX_PAYMENTS_CALLBACK_TOKEN` remains accepted as fallback compatibility.
- 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
- Legacy bridge callback compatibility remains at `/api/integrations/payments/approved`.
- Ballbox native MercadoPago webhook path is `/notification/mercadopago`.
- Ballbox native QR order creation path is `/api/integrations/payments/orders`.
- If neither `BALLBOX_PAYMENTS_SHARED_SECRET` nor legacy `BALLBOX_PAYMENTS_CALLBACK_TOKEN` is set in Ballbox, `/api/integrations/payments/approved` returns `500 PAYMENTS_SHARED_SECRET_NOT_CONFIGURED`.
- The callback payload is the approved-session handoff from the payment bridge into Ballbox.
- The callback contract now requires canonical `vendingMachineId`.
- The callback contract now also requires that `externalReference` parse as `payment:{vendingMachineId}:{machineTxnId}` and match the payload ids.
- `NEXT_PUBLIC_SITE_URL` is required if Ballbox should expose a concrete `recommendedCallbackUrl`.

## Validation Status
- Host app now includes local MercadoPago webhook signature validation and order fetch primitives.
- Remaining gap is operational/manual: one real public webhook + one real approved payment persisted and visible in `/admin/payments`.

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


edit

import { hasValidBearerAuthHeader, resolveSharedSecret } from "@/lib/shared-secret";

export function getPaymentsSharedSecretConfig() {
  return resolveSharedSecret([
    {
      envVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
      value: process.env.BALLBOX_PAYMENTS_SHARED_SECRET,
    },
    {
      envVar: "BALLBOX_PAYMENTS_CALLBACK_TOKEN",
      value: process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN,
    },
  ]);
}

export function isAuthorizedPaymentsIntegrationRequest(authorizationHeader: string | null) {
  return hasValidBearerAuthHeader(authorizationHeader, getPaymentsSharedSecretConfig().value);
}

export function getPaymentsIntegrationRequestAuthResult(authorizationHeader: string | null) {
  const sharedSecret = getPaymentsSharedSecretConfig();

  if (!sharedSecret.configured) {
    return {
      ok: false,
      error: "PAYMENTS_SHARED_SECRET_NOT_CONFIGURED" as const,
      envVar: sharedSecret.envVar,
      acceptedEnvVars: sharedSecret.acceptedEnvVars,
    };
  }

  if (!hasValidBearerAuthHeader(authorizationHeader, sharedSecret.value)) {
    return {
      ok: false,
      error: "UNAUTHORIZED" as const,
    };
  }

  return {
    ok: true,
  };
}

import { hasValidBearerAuthHeader, resolveSharedSecret } from "@/lib/shared-secret";

export function getPaymentsSharedSecretConfig() {
  return resolveSharedSecret([
    {
      envVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
      value: process.env.BALLBOX_PAYMENTS_SHARED_SECRET,
    },
  ]);
}

export function isAuthorizedPaymentsIntegrationRequest(authorizationHeader: string | null) {
  return hasValidBearerAuthHeader(authorizationHeader, getPaymentsSharedSecretConfig().value);
}

export function getPaymentsIntegrationRequestAuthResult(authorizationHeader: string | null) {
  const sharedSecret = getPaymentsSharedSecretConfig();

  if (!sharedSecret.configured) {
    return {
      ok: false,
      error: "PAYMENTS_SHARED_SECRET_NOT_CONFIGURED" as const,
      envVar: sharedSecret.envVar,
      acceptedEnvVars: sharedSecret.acceptedEnvVars,
    };
  }

  if (!hasValidBearerAuthHeader(authorizationHeader, sharedSecret.value)) {
    return {
      ok: false,
      error: "UNAUTHORIZED" as const,
    };
  }

  return {
    ok: true,
  };
}


Successfully replaced 1 block(s) in lib/payments-auth.ts.

    ...
  5     {
  6       envVar: "BALLBOX_PAYMENTS_SHARED_SECRET",
  7       value: process.env.BALLBOX_PAYMENTS_SHARED_SECRET,
  8     },
- 9     {
-10       envVar: "BALLBOX_PAYMENTS_CALLBACK_TOKEN",
-11       value: process.env.BALLBOX_PAYMENTS_CALLBACK_TOKEN,
-12     },
 13   ]);
 14 }
 15
 16 export function isAuthorizedPaymentsIntegrationRequest(authorizationHeader: string | null) {
    ...


import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPublicAppUrl } from "@/lib/public-url";
import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";

export async function GET() {
  const publicAppUrl = getPublicAppUrl();
  const sharedSecret = getPaymentsSharedSecretConfig();
  const mercadoPago = getMercadoPagoConfig();

  return paymentsJsonResponse({
    ok: true,
    integration: "payments",
    targetService: "ballbox host app",
    mode: "internal-module",
    configured: true,
    callbackUrl: "/api/integrations/payments/approved",
    mercadopagoWebhookPath: "/notification/mercadopago",
    recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
    sharedSecretConfigured: sharedSecret.configured,
    sharedSecretEnvVar: sharedSecret.envVar,
    mercadopago: {
      accessTokenConfigured: Boolean(mercadoPago.accessToken),
      accessTokenEnvVar: "BALLBOX_MERCADOPAGO_ACCESS_TOKEN",
      webhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
      webhookSecretEnvVar: "BALLBOX_MERCADOPAGO_WEBHOOK_SECRET",
      apiBaseUrl: mercadoPago.publicApiBaseUrl,
    },
    capabilities: [
      "approval callback intake",
      "mercadopago webhook intake",
      "mercadopago qr order creation",
      "mercadopago order fetch",
      "session monitoring",
      "status normalization",
      "operational dashboards",
    ],
  });
}

import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPublicAppUrl } from "@/lib/public-url";
import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";

export async function GET() {
  const publicAppUrl = getPublicAppUrl();
  const mercadoPago = getMercadoPagoConfig();

  return paymentsJsonResponse({
    ok: true,
    integration: "payments",
    targetService: "ballbox host app",
    mode: "internal-module",
    configured: true,
    orderCreatePath: "/api/integrations/payments/orders",
    mercadopagoWebhookPath: "/notification/mercadopago",
    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
    mercadopago: {
      accessTokenConfigured: Boolean(mercadoPago.accessToken),
      accessTokenEnvVar: "BALLBOX_MERCADOPAGO_ACCESS_TOKEN",
      webhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
      webhookSecretEnvVar: "BALLBOX_MERCADOPAGO_WEBHOOK_SECRET",
      externalPosIdConfigured: Boolean(mercadoPago.externalPosId),
      externalPosIdEnvVar: "BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID",
      apiBaseUrl: mercadoPago.publicApiBaseUrl,
    },
    capabilities: [
      "mercadopago webhook intake",
      "mercadopago qr order creation",
      "mercadopago order fetch",
      "approved-session persistence",
      "session monitoring",
      "status normalization",
      "operational dashboards",
    ],
  });
}

import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPublicAppUrl } from "@/lib/public-url";
import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";

export async function GET() {
  const items = await listRecentApprovedPayments();
  const publicAppUrl = getPublicAppUrl();
  const sharedSecret = getPaymentsSharedSecretConfig();
  const mercadoPago = getMercadoPagoConfig();

  return paymentsJsonResponse({
    ok: true,
    integration: "payments",
    targetService: "ballbox host app",
    mode: "internal",
    configured: true,
    path: "/api/integrations/payments/approved",
    orderCreatePath: "/api/integrations/payments/orders",
    mercadopagoWebhookPath: "/notification/mercadopago",
    recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
    sharedSecretConfigured: sharedSecret.configured,
    sharedSecretEnvVar: sharedSecret.envVar,
    mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
    mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
    items: items.map(serializePaymentSession),
  });
}

import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPublicAppUrl } from "@/lib/public-url";
import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";

export async function GET() {
  const items = await listRecentApprovedPayments();
  const publicAppUrl = getPublicAppUrl();
  const mercadoPago = getMercadoPagoConfig();

  return paymentsJsonResponse({
    ok: true,
    integration: "payments",
    targetService: "ballbox host app",
    mode: "internal",
    configured: true,
    orderCreatePath: "/api/integrations/payments/orders",
    mercadopagoWebhookPath: "/notification/mercadopago",
    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
    mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
    mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
    mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
    items: items.map(serializePaymentSession),
  });
}

import { NextResponse } from "next/server";
import { getAdminOverviewMetricsFromDb } from "@/lib/admin-overview";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
import { canonicalEntities, productAreas, runtimeSurface } from "@/lib/platform";

export async function GET() {
  const mercadoPago = getMercadoPagoConfig();
  const paymentsSharedSecret = getPaymentsSharedSecretConfig();
  const metrics = await getAdminOverviewMetricsFromDb();

  return NextResponse.json({
    ok: true,
    app: "ballbox-host",
    surfaces: {
      marketing: "/",
      admin: "/admin",
      runtime: runtimeSurface.href,
    },
    products: productAreas,
    canonicalEntities,
    modules: {
      network: {
        clubs: metrics.network.clubs,
        venues: metrics.network.venues,
        vendingMachines: metrics.network.machines,
      },
      players: {
        players: metrics.players.players,
        memberships: metrics.players.memberships,
      },
      sports: {
        coaches: metrics.sports.coaches,
        openMatches: metrics.sports.openMatches,
        classes: metrics.sports.classes,
      },
    },
    integrations: {
      payments: {
        configured: true,
        targetService: "ballbox host app",
        callbackPath: "/api/integrations/payments/approved",
        mercadopagoWebhookPath: "/notification/mercadopago",
        sharedSecretConfigured: paymentsSharedSecret.configured,
        sharedSecretEnvVar: paymentsSharedSecret.envVar,
        acceptedSharedSecretEnvVars: paymentsSharedSecret.acceptedEnvVars,
        mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
        mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
      },
      tvs: {
        configured: true,
        mode: "internal-module",
        targetService: "ballbox host app",
      },
    },
  });
}

import { NextResponse } from "next/server";
import { getAdminOverviewMetricsFromDb } from "@/lib/admin-overview";
import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
import { canonicalEntities, productAreas, runtimeSurface } from "@/lib/platform";

export async function GET() {
  const mercadoPago = getMercadoPagoConfig();
  const metrics = await getAdminOverviewMetricsFromDb();

  return NextResponse.json({
    ok: true,
    app: "ballbox-host",
    surfaces: {
      marketing: "/",
      admin: "/admin",
      runtime: runtimeSurface.href,
    },
    products: productAreas,
    canonicalEntities,
    modules: {
      network: {
        clubs: metrics.network.clubs,
        venues: metrics.network.venues,
        vendingMachines: metrics.network.machines,
      },
      players: {
        players: metrics.players.players,
        memberships: metrics.players.memberships,
      },
      sports: {
        coaches: metrics.sports.coaches,
        openMatches: metrics.sports.openMatches,
        classes: metrics.sports.classes,
      },
    },
    integrations: {
      payments: {
        configured: true,
        targetService: "ballbox host app",
        orderCreatePath: "/api/integrations/payments/orders",
        mercadopagoWebhookPath: "/notification/mercadopago",
        mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
        mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
        mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
      },
      tvs: {
        configured: true,
        mode: "internal-module",
        targetService: "ballbox host app",
      },
    },
  });
}


Successfully replaced 1 block(s) in app/api/integrations/payments/health/route.ts.

- 1 import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
  2 import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
  3 import { getPublicAppUrl } from "@/lib/public-url";
  4 import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
  5
  6 export async function GET() {
  7   const publicAppUrl = getPublicAppUrl();
- 8   const sharedSecret = getPaymentsSharedSecretConfig();
  9   const mercadoPago = getMercadoPagoConfig();
 10
 11   return paymentsJsonResponse({
 12     ok: true,
 13     integration: "payments",
 14     targetService: "ballbox host app",
 15     mode: "internal-module",
 16     configured: true,
-17     callbackUrl: "/api/integrations/payments/approved",
+15     orderCreatePath: "/api/integrations/payments/orders",
 18     mercadopagoWebhookPath: "/notification/mercadopago",
-19     recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
 20     recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
-21     sharedSecretConfigured: sharedSecret.configured,
-22     sharedSecretEnvVar: sharedSecret.envVar,
 23     mercadopago: {
 24       accessTokenConfigured: Boolean(mercadoPago.accessToken),
 25       accessTokenEnvVar: "BALLBOX_MERCADOPAGO_ACCESS_TOKEN",
 26       webhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
 27       webhookSecretEnvVar: "BALLBOX_MERCADOPAGO_WEBHOOK_SECRET",
+23       externalPosIdConfigured: Boolean(mercadoPago.externalPosId),
+24       externalPosIdEnvVar: "BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID",
 28       apiBaseUrl: mercadoPago.publicApiBaseUrl,
 29     },
 30     capabilities: [
-31       "approval callback intake",
 32       "mercadopago webhook intake",
 33       "mercadopago qr order creation",
 34       "mercadopago order fetch",
+31       "approved-session persistence",
 35       "session monitoring",
 36       "status normalization",
 37       "operational dashboards",
 38     ],
    ...


Successfully replaced 1 block(s) in app/api/integrations/payments/sessions/route.ts.

  1 import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
- 2 import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
  3 import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
  4 import { getPublicAppUrl } from "@/lib/public-url";
  5 import { paymentsJsonResponse } from "@/lib/http/payments-route-helpers";
  6
  7 export async function GET() {
  8   const items = await listRecentApprovedPayments();
  9   const publicAppUrl = getPublicAppUrl();
-10   const sharedSecret = getPaymentsSharedSecretConfig();
 11   const mercadoPago = getMercadoPagoConfig();
 12
 13   return paymentsJsonResponse({
 14     ok: true,
 15     integration: "payments",
 16     targetService: "ballbox host app",
 17     mode: "internal",
 18     configured: true,
-19     path: "/api/integrations/payments/approved",
 20     orderCreatePath: "/api/integrations/payments/orders",
 21     mercadopagoWebhookPath: "/notification/mercadopago",
-22     recommendedCallbackUrl: publicAppUrl ? `${publicAppUrl}/api/integrations/payments/approved` : null,
 23     recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
-24     sharedSecretConfigured: sharedSecret.configured,
-25     sharedSecretEnvVar: sharedSecret.envVar,
 26     mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
 27     mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
+22     mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
 28     items: items.map(serializePaymentSession),
 29   });
 30 }


Successfully replaced 1 block(s) in app/api/admin/summary/route.ts.

  1 import { NextResponse } from "next/server";
  2 import { getAdminOverviewMetricsFromDb } from "@/lib/admin-overview";
  3 import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
- 4 import { getPaymentsSharedSecretConfig } from "@/lib/payments-auth";
  5 import { canonicalEntities, productAreas, runtimeSurface } from "@/lib/platform";
  6
  7 export async function GET() {
  8   const mercadoPago = getMercadoPagoConfig();
- 9   const paymentsSharedSecret = getPaymentsSharedSecretConfig();
 10   const metrics = await getAdminOverviewMetricsFromDb();
 11
 12   return NextResponse.json({
 13     ok: true,
    ...
 38     integrations: {
 39       payments: {
 40         configured: true,
 41         targetService: "ballbox host app",
-42         callbackPath: "/api/integrations/payments/approved",
+40         orderCreatePath: "/api/integrations/payments/orders",
 43         mercadopagoWebhookPath: "/notification/mercadopago",
-44         sharedSecretConfigured: paymentsSharedSecret.configured,
-45         sharedSecretEnvVar: paymentsSharedSecret.envVar,
-46         acceptedSharedSecretEnvVars: paymentsSharedSecret.acceptedEnvVars,
 47         mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
 48         mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
+44         mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
 49       },
 50       tvs: {
 51         configured: true,
 52         mode: "internal-module",
    ...