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