lib/http/payments-route-helpers.ts:26:  if (fieldName?.includes("vendingMachineId") || fieldName?.includes("VendingMachine")) {
lib/platform.ts:24:  | "PaymentSession"
lib/platform.ts:97:  "PaymentSession",
app/api/integrations/payments/sessions/route.ts:1:import { listRecentApprovedPayments, serializePaymentSession } from "@/lib/payments";
app/api/integrations/payments/sessions/route.ts:2:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/integrations/payments/sessions/route.ts:9:  const mercadoPago = getMercadoPagoConfig();
app/api/integrations/payments/sessions/route.ts:18:    mercadopagoWebhookPath: "/notification/mercadopago",
app/api/integrations/payments/sessions/route.ts:19:    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
app/api/integrations/payments/sessions/route.ts:20:    mercadopagoAccessTokenConfigured: Boolean(mercadoPago.accessToken),
app/api/integrations/payments/sessions/route.ts:21:    mercadopagoWebhookSecretConfigured: Boolean(mercadoPago.webhookSecret),
app/api/integrations/payments/sessions/route.ts:22:    mercadopagoExternalPosIdConfigured: Boolean(mercadoPago.externalPosId),
app/api/integrations/payments/sessions/route.ts:23:    items: items.map(serializePaymentSession),
tests/contracts/payments/admin-routes.test.ts:7:const createPaymentSessionInDb = vi.fn();
tests/contracts/payments/admin-routes.test.ts:8:const deletePaymentSessionInDb = vi.fn();
tests/contracts/payments/admin-routes.test.ts:9:const listPaymentSessionsFromDb = vi.fn();
tests/contracts/payments/admin-routes.test.ts:10:const serializePaymentSession = vi.fn();
tests/contracts/payments/admin-routes.test.ts:11:const updatePaymentSessionInDb = vi.fn();
tests/contracts/payments/admin-routes.test.ts:17:  createPaymentSessionInDb,
tests/contracts/payments/admin-routes.test.ts:18:  deletePaymentSessionInDb,
tests/contracts/payments/admin-routes.test.ts:19:  listPaymentSessionsFromDb,
tests/contracts/payments/admin-routes.test.ts:20:  serializePaymentSession,
tests/contracts/payments/admin-routes.test.ts:21:  updatePaymentSessionInDb,
tests/contracts/payments/admin-routes.test.ts:40:    createPaymentSessionInDb.mockResolvedValue(session);
tests/contracts/payments/admin-routes.test.ts:41:    serializePaymentSession.mockReturnValue({ sessionId: "ps_001", amountMinor: 1200 });
tests/contracts/payments/admin-routes.test.ts:50:          vendingMachineId: "vm_001",
tests/contracts/payments/admin-routes.test.ts:53:          externalReference: "payment-vm_vm_001-txn_txn_001",
tests/contracts/payments/admin-routes.test.ts:86:    createPaymentSessionInDb.mockRejectedValue(error);
tests/contracts/payments/admin-routes.test.ts:88:    getPrismaForeignKeyFieldName.mockReturnValue("PaymentSession_vendingMachineId_fkey");
tests/contracts/payments/admin-routes.test.ts:97:          vendingMachineId: "vm_missing",
tests/contracts/payments/admin-routes.test.ts:100:          externalReference: "payment-vm_vm_missing-txn_txn_001",
tests/contracts/payments/admin-routes.test.ts:117:    updatePaymentSessionInDb.mockRejectedValue(error);
tests/contracts/payments/admin-routes.test.ts:119:    getPrismaForeignKeyFieldName.mockReturnValue("PaymentSession_productId_fkey");
tests/contracts/payments/admin-routes.test.ts:138:    updatePaymentSessionInDb.mockRejectedValue(error);
tests/contracts/payments/admin-routes.test.ts:146:        body: JSON.stringify({ externalReference: "duplicate-ref" }),
tests/contracts/payments/admin-routes.test.ts:157:    deletePaymentSessionInDb.mockResolvedValue(false);
lib/contracts/payments.ts:30:  vendingMachineId: z.string().trim().min(1, "Vending machine id is required").max(191, "Vending machine id is too long"),
lib/contracts/payments.ts:33:  externalReference: z.string().trim().min(1, "External reference is required").max(191, "External reference is too long"),
lib/contracts/payments.ts:45:  vendingMachineId: z.string().trim().min(1, "Vending machine id is required").max(191, "Vending machine id is too long"),
lib/contracts/payments.ts:48:  externalReference: z.string().trim().min(1, "External reference is required").max(191, "External reference is too long"),
lib/contracts/payments.ts:59:    vendingMachineId: z
lib/contracts/payments.ts:72:    externalReference: z
lib/contracts/payments.ts:87:      value.vendingMachineId !== undefined ||
lib/contracts/payments.ts:90:      value.externalReference !== undefined ||
lib/contracts/payments.ts:105:  vendingMachineId: z.string().trim().min(1, "Vending machine id is required").max(191, "Vending machine id is too long"),
lib/contracts/payments.ts:110:  externalPosId: z.string().trim().min(1, "External POS id is required").max(191, "External POS id is too long").optional(),
lib/contracts/payments.ts:126:        externalReference: z.string(),
lib/contracts/payments.ts:127:        vendingMachineId: z.string(),
lib/contracts/payments.ts:133:        externalPosId: z.string(),
tests/contracts/payments/orders-route.test.ts:5:const createMercadoPagoQrOrder = vi.fn();
tests/contracts/payments/orders-route.test.ts:18:vi.mock("@/lib/payments-mercadopago", () => ({
tests/contracts/payments/orders-route.test.ts:19:  getMercadoPagoConfig: () => ({
tests/contracts/payments/orders-route.test.ts:22:    externalPosId: "STORE001POS001",
tests/contracts/payments/orders-route.test.ts:23:    publicApiBaseUrl: "https://api.mercadopago.com",
tests/contracts/payments/orders-route.test.ts:25:  createMercadoPagoQrOrder,
tests/contracts/payments/orders-route.test.ts:37:  it("creates a mercadopago qr order with canonical reference", async () => {
tests/contracts/payments/orders-route.test.ts:40:    createMercadoPagoQrOrder.mockResolvedValue({
tests/contracts/payments/orders-route.test.ts:44:        externalReference: "payment-vm_vm_001-txn_txn_123",
tests/contracts/payments/orders-route.test.ts:45:        vendingMachineId: "vm_001",
tests/contracts/payments/orders-route.test.ts:51:        externalPosId: "STORE001POS001",
tests/contracts/payments/orders-route.test.ts:65:          vendingMachineId: "vm_001",
tests/contracts/payments/orders-route.test.ts:75:    expect(createMercadoPagoQrOrder).toHaveBeenCalledWith(
tests/contracts/payments/orders-route.test.ts:77:        vendingMachineId: "vm_001",
tests/contracts/payments/orders-route.test.ts:80:        externalPosId: "STORE001POS001",
tests/contracts/payments/orders-route.test.ts:81:        notificationUrl: "https://ballbox.app/notification/mercadopago?source_news=webhooks",
tests/contracts/payments/orders-route.test.ts:85:    const call = createMercadoPagoQrOrder.mock.calls[0]?.[0] as { externalReference: string };
tests/contracts/payments/orders-route.test.ts:86:    expect(call.externalReference.startsWith("payment-vm_vm_001-txn_txn_")).toBe(true);
tests/contracts/payments/orders-route.test.ts:92:    createMercadoPagoQrOrder.mockResolvedValue({
tests/contracts/payments/orders-route.test.ts:96:        externalReference: "payment-vm_vm_001-txn_txn_124",
tests/contracts/payments/orders-route.test.ts:97:        vendingMachineId: "vm_001",
tests/contracts/payments/orders-route.test.ts:103:        externalPosId: "STORE001POS001",
tests/contracts/payments/orders-route.test.ts:117:          vendingMachineId: "vm_001",
tests/contracts/payments/orders-route.test.ts:124:    expect(createMercadoPagoQrOrder).toHaveBeenCalledWith(
tests/contracts/payments/orders-route.test.ts:126:        vendingMachineId: "vm_001",
tests/contracts/payments/orders-route.test.ts:129:        externalPosId: "STORE001POS001",
tests/contracts/payments/orders-route.test.ts:130:        notificationUrl: "https://ballbox.app/notification/mercadopago?source_news=webhooks",
app/api/integrations/payments/health/route.ts:1:import { getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/integrations/payments/health/route.ts:7:  const mercadoPago = getMercadoPagoConfig();
app/api/integrations/payments/health/route.ts:16:    mercadopagoWebhookPath: "/notification/mercadopago",
app/api/integrations/payments/health/route.ts:17:    recommendedMercadoPagoWebhookUrl: publicAppUrl ? `${publicAppUrl}/notification/mercadopago` : null,
app/api/integrations/payments/health/route.ts:18:    mercadopago: {
app/api/integrations/payments/health/route.ts:23:      externalPosIdConfigured: Boolean(mercadoPago.externalPosId),
app/api/integrations/payments/health/route.ts:24:      externalPosIdEnvVar: "BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID",
app/api/integrations/payments/health/route.ts:28:      "mercadopago webhook intake",
app/api/integrations/payments/health/route.ts:29:      "mercadopago qr order creation",
app/api/integrations/payments/health/route.ts:30:      "mercadopago order fetch",
tests/contracts/payments/auth.test.ts:21:        "Use the native MercadoPago flow: create orders at /api/integrations/payments/orders and receive webhooks at /notification/mercadopago.",
tests/contracts/payments/auth.test.ts:30:      message: "Use the native MercadoPago webhook at /notification/mercadopago.",
tests/contracts/payments/auth.test.ts:34:  it("reports native mercadopago health status and config flags", async () => {
tests/contracts/payments/auth.test.ts:48:      mercadopagoWebhookPath: "/notification/mercadopago",
tests/contracts/payments/auth.test.ts:49:      mercadopago: {
tests/contracts/payments/auth.test.ts:52:        externalPosIdConfigured: true,
app/api/integrations/payments/orders/route.ts:12:import { createMercadoPagoQrOrder, getMercadoPagoConfig } from "@/lib/payments-mercadopago";
app/api/integrations/payments/orders/route.ts:22:  const config = getMercadoPagoConfig();
app/api/integrations/payments/orders/route.ts:29:      externalPosId: Boolean(config.externalPosId),
app/api/integrations/payments/orders/route.ts:34:      externalPosId: config.externalPosId,
app/api/integrations/payments/orders/route.ts:46:    where: { id: parsedBody.data.vendingMachineId },
app/api/integrations/payments/orders/route.ts:61:  const config = getMercadoPagoConfig();
app/api/integrations/payments/orders/route.ts:62:  const externalPosId = parsedBody.data.externalPosId ?? config.externalPosId;
app/api/integrations/payments/orders/route.ts:63:  if (!externalPosId) {
app/api/integrations/payments/orders/route.ts:65:      field: "externalPosId",
app/api/integrations/payments/orders/route.ts:71:  const externalReference = buildPaymentExternalReference({
app/api/integrations/payments/orders/route.ts:72:    vendingMachineId: parsedBody.data.vendingMachineId,
app/api/integrations/payments/orders/route.ts:93:  const notificationUrl = publicAppUrl ? `${publicAppUrl}/notification/mercadopago?source_news=webhooks` : undefined;
app/api/integrations/payments/orders/route.ts:95:  const createResult = await createMercadoPagoQrOrder({
app/api/integrations/payments/orders/route.ts:96:    vendingMachineId: parsedBody.data.vendingMachineId,
app/api/integrations/payments/orders/route.ts:100:    externalReference,
app/api/integrations/payments/orders/route.ts:102:    externalPosId,
lib/network-backend-vending-machines.ts:36:  vendingMachineId: string,
lib/network-backend-vending-machines.ts:43:    where: { id: vendingMachineId },
lib/network-backend-vending-machines.ts:50:    where: { id: vendingMachineId },
lib/network-backend-vending-machines.ts:72:export async function deleteVendingMachineInDb(venueId: string, vendingMachineId: string) {
lib/network-backend-vending-machines.ts:74:    where: { id: vendingMachineId },
lib/network-backend-vending-machines.ts:108:    where: { id: vendingMachineId },
lib/admin-overview.ts:30:      distinct: ["vendingMachineId"],
lib/admin-overview.ts:31:      select: { vendingMachineId: true },
app/api/integrations/payments/approved/route.ts:10:      message: "Use the native MercadoPago flow: create orders at /api/integrations/payments/orders and receive webhooks at /notification/mercadopago.",
app/api/integrations/payments/approved/route.ts:21:      message: "Use the native MercadoPago webhook at /notification/mercadopago.",
lib/payments-mercadopago.ts:3:type MercadoPagoNotificationPayload = {
lib/payments-mercadopago.ts:11:type MercadoPagoOrderPayload = {
lib/payments-mercadopago.ts:29:type MercadoPagoConfig = {
lib/payments-mercadopago.ts:32:  externalPosId: string | null;
lib/payments-mercadopago.ts:49:export function getMercadoPagoConfig(): MercadoPagoConfig {
lib/payments-mercadopago.ts:56:    externalPosId: resolveStringEnv(process.env.BALLBOX_MERCADOPAGO_EXTERNAL_POS_ID),
lib/payments-mercadopago.ts:57:    publicApiBaseUrl: normalizeBaseUrl(process.env.BALLBOX_MERCADOPAGO_API_BASE_URL ?? "https://api.mercadopago.com"),
lib/payments-mercadopago.ts:61:function amountMinorToMercadoPagoAmount(amountMinor: number) {
lib/payments-mercadopago.ts:71:export function extractMercadoPagoOrderId(payload: MercadoPagoNotificationPayload, requestUrl: string) {
lib/payments-mercadopago.ts:95:export async function fetchMercadoPagoOrder(orderId: string) {
lib/payments-mercadopago.ts:96:  const config = getMercadoPagoConfig();
lib/payments-mercadopago.ts:124:    order: (await response.json()) as MercadoPagoOrderPayload,
lib/payments-mercadopago.ts:128:export function mapMercadoPagoOrderToApprovedPayment(order: MercadoPagoOrderPayload):
lib/payments-mercadopago.ts:140:  const externalReference = order.external_reference?.trim() ?? "";
lib/payments-mercadopago.ts:142:    externalReference,
lib/payments-mercadopago.ts:143:    vendingMachineId: "",
lib/payments-mercadopago.ts:185:      vendingMachineId: correlation.parsedReference.vendingMachineId,
lib/payments-mercadopago.ts:188:      externalReference,
lib/payments-mercadopago.ts:199:export async function createMercadoPagoQrOrder(input: {
lib/payments-mercadopago.ts:200:  vendingMachineId: string;
lib/payments-mercadopago.ts:204:  externalReference: string;
lib/payments-mercadopago.ts:206:  externalPosId: string;
lib/payments-mercadopago.ts:212:  const config = getMercadoPagoConfig();
lib/payments-mercadopago.ts:233:      total_amount: amountMinorToMercadoPagoAmount(input.amountMinor),
lib/payments-mercadopago.ts:235:      external_reference: input.externalReference,
lib/payments-mercadopago.ts:239:          external_pos_id: input.externalPosId,
lib/payments-mercadopago.ts:246:            amount: amountMinorToMercadoPagoAmount(input.amountMinor),
lib/payments-mercadopago.ts:253:          unit_price: amountMinorToMercadoPagoAmount(input.amountMinor),
lib/payments-mercadopago.ts:262:            notification_url: input.notificationUrl,
lib/payments-mercadopago.ts:292:      externalReference: payload?.external_reference ?? input.externalReference,
lib/payments-mercadopago.ts:293:      vendingMachineId: input.vendingMachineId,
lib/payments-mercadopago.ts:299:      externalPosId: input.externalPosId,
lib/payments.ts:14:  vendingMachineId: string;
lib/payments.ts:17:  externalReference: string;
lib/payments.ts:26:type PaymentSessionRecord = Awaited<ReturnType<typeof listPaymentSessionsFromDb>>[number];
lib/payments.ts:30:  vendingMachineId: string;
lib/payments.ts:33:  externalReference: string;
lib/payments.ts:43:    vendingMachineId: input.vendingMachineId,
lib/payments.ts:46:    externalReference: input.externalReference,
lib/payments.ts:57:export function buildPaymentExternalReference(input: { vendingMachineId: string; machineTxnId: string }) {
lib/payments.ts:58:  return `payment-vm_${input.vendingMachineId}-txn_${input.machineTxnId}`;
lib/payments.ts:61:export function parsePaymentExternalReference(externalReference: string) {
lib/payments.ts:62:  const match = externalReference.match(/^payment-vm_(.+)-txn_(.+)$/);
lib/payments.ts:68:  const [, vendingMachineId, machineTxnId] = match;
lib/payments.ts:69:  if (!vendingMachineId || !machineTxnId) {
lib/payments.ts:75:    vendingMachineId,
lib/payments.ts:81:  externalReference: string;
lib/payments.ts:82:  vendingMachineId: string;
lib/payments.ts:85:  const parsedReference = parsePaymentExternalReference(input.externalReference);
lib/payments.ts:89:    externalReferenceIsCanonical: parsedReference !== null,
lib/payments.ts:90:    vendingMachineIdMatchesReference: parsedReference ? parsedReference.vendingMachineId === input.vendingMachineId : false,
lib/payments.ts:95:export function serializePaymentSession(item: PaymentSessionRecord) {
lib/payments.ts:97:    externalReference: item.externalReference,
lib/payments.ts:98:    vendingMachineId: item.vendingMachineId,
lib/payments.ts:104:    vendingMachineId: item.vendingMachineId,
lib/payments.ts:107:    externalReference: item.externalReference,
lib/payments.ts:119:export type SerializedPaymentSession = ReturnType<typeof serializePaymentSession>;
lib/payments.ts:127:      vendingMachineId: input.vendingMachineId,
lib/payments.ts:130:      externalReference: input.externalReference,
lib/payments.ts:140:      vendingMachineId: input.vendingMachineId,
lib/payments.ts:143:      externalReference: input.externalReference,
lib/payments.ts:158:  return listPaymentSessionsFromDb(limit);
lib/payments.ts:161:export async function listPaymentSessionsFromDb(limit = 50) {
lib/payments.ts:190:  const items = await listPaymentSessionsFromDb();
lib/payments.ts:191:  const serializedItems = items.map(serializePaymentSession);
lib/payments.ts:193:    (item) => !item.correlation.vendingMachineIdMatchesReference || !item.correlation.machineTxnIdMatchesReference
lib/payments.ts:209:      vendingMachines: new Set(items.map((item) => item.vendingMachineId)).size,
lib/payments.ts:220:export async function createPaymentSessionInDb(input: {
lib/payments.ts:222:  vendingMachineId: string;
lib/payments.ts:225:  externalReference: string;
lib/payments.ts:238:      vendingMachineId: input.vendingMachineId,
lib/payments.ts:241:      externalReference: input.externalReference,
lib/payments.ts:251:        vendingMachineId: input.vendingMachineId,
lib/payments.ts:254:        externalReference: input.externalReference,
lib/payments.ts:266:export async function updatePaymentSessionInDb(
lib/payments.ts:269:    vendingMachineId?: string;
lib/payments.ts:272:    externalReference?: string;
lib/payments.ts:292:      vendingMachineId: input.vendingMachineId,
lib/payments.ts:295:      externalReference: input.externalReference,
lib/payments.ts:304:        vendingMachineId: input.vendingMachineId ?? existing.vendingMachineId,
lib/payments.ts:307:        externalReference: input.externalReference ?? existing.externalReference,
lib/payments.ts:321:export async function deletePaymentSessionInDb(sessionId: string) {
tests/contracts/network/routes.test.ts:223:    const { PATCH } = await import("@/app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route");
tests/contracts/network/routes.test.ts:230:      { params: Promise.resolve({ venueId: "venue_001", vendingMachineId: "vm_001" }) }
tests/contracts/network/routes.test.ts:267:    const { DELETE } = await import("@/app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route");
tests/contracts/network/routes.test.ts:269:      params: Promise.resolve({ venueId: "venue_001", vendingMachineId: "vm_001" }),
app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route.ts:20:  context: { params: Promise<{ venueId: string; vendingMachineId: string }> }
app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route.ts:22:  const { venueId, vendingMachineId } = await context.params;
app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route.ts:30:    const machine = await updateVendingMachineInDb(venueId, vendingMachineId, parsedBody.data);
app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route.ts:48:  context: { params: Promise<{ venueId: string; vendingMachineId: string }> }
app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route.ts:50:  const { venueId, vendingMachineId } = await context.params;
app/api/network/venues/[venueId]/vending-machines/[vendingMachineId]/route.ts:51:  const result = await deleteVendingMachineInDb(venueId, vendingMachineId);
prisma/schema.prisma:26:enum PaymentSessionStatus {
prisma/schema.prisma:153:  paymentSessions PaymentSession[]
prisma/schema.prisma:175:  vendingMachine   VendingMachine? @relation(fields: [vendingMachineId], references: [id])
prisma/schema.prisma:176:  vendingMachineId String?         @unique
prisma/schema.prisma:237:  paymentSessions PaymentSession[]
prisma/schema.prisma:242:model PaymentSession {
prisma/schema.prisma:244:  vendingMachine    VendingMachine          @relation(fields: [vendingMachineId], references: [id])
prisma/schema.prisma:245:  vendingMachineId  String
prisma/schema.prisma:249:  externalReference String                  @unique
prisma/schema.prisma:252:  status            PaymentSessionStatus @default(APPROVED)
prisma/schema.prisma:262:  @@index([vendingMachineId, approvedAt])
prisma/seed-data.ts:23:  vendingMachineId?: string;
prisma/seed-data.ts:139:    vendingMachineId: "vm_004",
prisma/seed-data.ts:149:    vendingMachineId: "vm_003",
prisma/seed.ts:290:        vendingMachineId: screen.vendingMachineId ?? null,
prisma/seed.ts:298:        vendingMachineId: screen.vendingMachineId ?? null,
app/notification/mercadopago/route.ts:2:import { recordApprovedPayment, serializePaymentSession } from "@/lib/payments";
app/notification/mercadopago/route.ts:4:  extractMercadoPagoOrderId,
app/notification/mercadopago/route.ts:5:  fetchMercadoPagoOrder,
app/notification/mercadopago/route.ts:6:  mapMercadoPagoOrderToApprovedPayment,
app/notification/mercadopago/route.ts:7:} from "@/lib/payments-mercadopago";
app/notification/mercadopago/route.ts:20:  const orderId = extractMercadoPagoOrderId(payload, request.url);
app/notification/mercadopago/route.ts:23:    console.info("[payments][mercadopago-webhook] ignored", {
app/notification/mercadopago/route.ts:41:  const orderResult = await fetchMercadoPagoOrder(orderId as string);
app/notification/mercadopago/route.ts:43:    console.warn("[payments][mercadopago-webhook] order fetch failed", {
app/notification/mercadopago/route.ts:61:  const mapped = mapMercadoPagoOrderToApprovedPayment(orderResult.order);
app/notification/mercadopago/route.ts:63:    console.info("[payments][mercadopago-webhook] ignored", {
app/notification/mercadopago/route.ts:82:  console.info("[payments][mercadopago-webhook] approved persisted", {
app/notification/mercadopago/route.ts:93:    source: "mercadopago-webhook",
app/notification/mercadopago/route.ts:94:    item: serializePaymentSession(saved),
tests/unit/payments/mercadopago.test.ts:2:import { extractMercadoPagoOrderId, mapMercadoPagoOrderToApprovedPayment } from "@/lib/payments-mercadopago";


**Planning documentation structure**

I need to create a technical implementation document and update the plan or index. I’m considering whether to include a queue and maybe a repo map. For this, I’ll structure the document as docs/payments-machine-implementation-rfc.md, which sounds like a good start. I want to ensure everything is clear and organized so it’s easy to follow. Let’s outline it and figure out the best way to present the information!

write

# Payments machine implementation RFC

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

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

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

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

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

## Proposed architecture

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Recommendation:
- add a lightweight table for initiated machine payments

Suggested record name:
- `PaymentOperation`

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

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

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

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

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

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

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

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

resolvePaymentOwnerForMachine(vendingMachineId)

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

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

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

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

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

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

## Data model recommendation

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

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

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

This fallback is weaker and should be temporary only.

## Webhook resolution strategy

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

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

Use fallback only as recovery, not primary mechanism.

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

## Suggested implementation steps

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

### Step B
Add owner resolution layer.

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

### Step D
Add `PaymentOperation` persistence.

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

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

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

## Testing plan

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

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

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

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

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

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

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


Successfully wrote 9475 bytes to docs/payments-machine-implementation-rfc.md


edit

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

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


Successfully replaced 1 block(s) in PLAN.md.

     ...
 226    - current future candidate: Mercado Pago split / marketplace
 227
 228 ### Payments Track Docs
 229 - Master rollout RFC: `docs/payments-machine-rollout-rfc.md`
+230 - Technical implementation RFC: `docs/payments-machine-implementation-rfc.md`
 230 - Implemented provider layer: `docs/implemented-payments.md`
 231 - TCN direction and target flow: `docs/tcn-payments-integration-plan.md`
 232 - Open external questions: `docs/tcn-payments-open-questions.md`
 233 - Ordered execution sequence: `docs/tcn-payments-execution-plan.md`
     ...


- `docs/payments-machine-rollout-rfc.md`
- `docs/tcn-payments-integration-plan.md`

- `docs/payments-machine-rollout-rfc.md`
- `docs/payments-machine-implementation-rfc.md`
- `docs/tcn-payments-integration-plan.md`


Successfully replaced 1 block(s) in docs/index.md.

    ...
 49 - `docs/classes-mvp-rfc.md`
 50 - `docs/coaches-coordination-gap-analysis.md`
 51 - `docs/atc-mock-integration-plan.md`
 52 - `docs/payments-machine-rollout-rfc.md`
+53 - `docs/payments-machine-implementation-rfc.md`
 53 - `docs/tcn-payments-integration-plan.md`
 54 - `docs/tcn-payments-open-questions.md`
 55 - `docs/tcn-payments-execution-plan.md`
 56 - `docs/payments-club-settlement-strategy.md`


- Notes:
  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
  - do not assume extra scanner hardware for the buyer flow; the open question is adapter contract detail, not QR display feasibility
  - club settlement automation can be revisited after the machine-facing flow is stable; manual bookkeeping is the current default

- Notes:
  - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
  - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
  - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
  - do not assume extra scanner hardware for the buyer flow; the open question is adapter contract detail, not QR display feasibility
  - club settlement automation can be revisited after the machine-facing flow is stable; manual bookkeeping is the current default


Successfully replaced 1 block(s) in docs/next-steps-queue.md.

     ...
 151   - document the exact adapter contract and remaining TCN unknowns before implementation
 152 - Notes:
 153   - TCN protocol evidence is documented in `docs/tcn-payments-integration-plan.md`
 154   - master rollout direction is documented in `docs/payments-machine-rollout-rfc.md`
+155   - technical implementation shape is documented in `docs/payments-machine-implementation-rfc.md`
 155   - do not assume extra scanner hardware for the buyer flow; the open question is adapter contract detail, not QR display feasibility
 156   - club settlement automation can be revisited after the machine-facing flow is stable; manual bookkeeping is the current default
 157 - Expected payoff: closes the actual machine integration path without fragmenting Ballbox collection vs tenant-direct collection into separate technical stacks
 158
     ...


bash

node scripts/check-doc-references.mjs && git status --short


Docs reference check passed for README.md, PLAN.md, and docs/index.md
 M PLAN.md
 M docs/index.md
 M docs/next-steps-queue.md
?? docs/payments-machine-implementation-rfc.md


Hecho.

Commit hecho
- `85457f5` — `docs: add payments machine rollout master plan`

Y seguí.

Nuevo
- `docs/payments-machine-implementation-rfc.md`

Actualizado
- `PLAN.md`
- `docs/index.md`
- `docs/next-steps-queue.md`

Validado
- doc refs check ✅

Qué cubre el RFC técnico
- resolver owner de pago por máquina
- separar config resolution de env global
- soportar `BALLBOX` y `TENANT` sin duplicar stack
- introducir capa shared de machine-payment service
- proponer `PaymentOperation` como registro pre-aprobación
- hacer webhook resolution confiable por `providerOrderId`
- dejar TCN route arriba de esa service layer
- hacer que admin QR route reuse la misma base

Mi recomendación técnica sigue siendo:
- no hackear esto solo con env vars
- agregar:
  - owner mode por máquina
  - owner key por máquina
  - `PaymentOperation` liviano
- recién después conectar TCN route

Estado actual de git
- hay cambios sin commitear de este segundo paso:
  - `PLAN.md`
  - `docs/index.md`
  - `docs/next-steps-queue.md`
  - `docs/payments-machine-implementation-rfc.md`

Si querés, siguiente hago una de dos:
1. **commit de este RFC técnico**
2. **bajar esto a implementación real** empezando por modelado/schema para `paymentOwnerMode` + `PaymentOperation`

Mi sugerencia: commit primero, después implementación.


comit now all to continue on remote


git add PLAN.md docs/index.md docs/next-steps-queue.md docs/payments-machine-implementation-rfc.md && git commit -m "docs: define machine payments implementation shape" && git status --short && git log --oneline -2


[main 88a6d69] docs: define machine payments implementation shape
 4 files changed, 301 insertions(+)
 create mode 100644 docs/payments-machine-implementation-rfc.md
88a6d69 docs: define machine payments implementation shape
85457f5 docs: add payments machine rollout master plan


Hecho.

Commits nuevos
- `88a6d69` — `docs: define machine payments implementation shape`
- `85457f5` — `docs: add payments machine rollout master plan`

Repo limpio. Ready para seguir remoto.