# Plan: Novasis Pay (microservicio multi-provider de pagos)

**Producto**: **Novasis Pay**
**Repo previsto**: `novasis-pay` (o `payments-gateway` como nombre interno del servicio)
**Dominios previstos**:
- API: `api.pay.novasis.io` (o similar — confirmar al setup)
- Hosted checkout: `pay.novasis.io`
- SDK CDN: `js.pay.novasis.io`
- Dashboard externo (Fase 9): `dashboard.pay.novasis.io`

**Fecha inicio diseño**: 2026-06-06
**Estado**: Fase 1 completa + Admin SPA operativo + ERP conectado + **Fase 2 (cobros desde factura) end-to-end funcional contra dLocal Go real** (intent → checkout session → pago → sync manual). Faltan webhooks reales (túnel) y aplicación contable automática.
**Última actualización**: 2026-06-10 (sync pull-based ERP → gateway → dLocal operativo; redesign Cobros UI; recuperación 409 customer; mínimo PYG 10000)
**Reemplaza**: `plan-bancard-vpos-compra-asistida.md` (el módulo Bancard interno del ERP se reabsorbe como un adapter más del nuevo gateway, en fase futura).

## Credenciales

- **dLocal Go**: cuenta activa. Credenciales (`X-Login`, `X-Trans-Key`, `Secret-Key`) las proveerá el dueño del proyecto cuando se llegue a Fase 2. Hasta entonces, no figuran en este plan ni en el repo.
- Convención al integrarse: variables `.env` del gateway (`DLOCAL_GO_X_LOGIN`, `DLOCAL_GO_X_TRANS_KEY`, `DLOCAL_GO_SECRET_KEY`, `DLOCAL_GO_BASE_URL`, `DLOCAL_GO_MODE=sandbox|live`). Encriptadas en DB vía `merchant_provider_config.credenciales_encrypted` cuando se configuran por merchant en runtime.

---

## Objetivo

Construir una **pasarela de pagos independiente** que:
- Se pueda usar **dentro del ERP smartfactvoice** y también de forma **standalone** por terceros (e-commerces, otras apps).
- Soporte **múltiples providers**: dLocal Go (primero), Bancard, Pagopar, Dinelco.
- Sea **white-label**: el branding lo elige el merchant, no el provider.
- Escale a múltiples países LATAM en el futuro (arranca con Paraguay).

---

## Modelo de negocio — Gateway puro ("bring your own merchant")

**Decidido 2026-06-10**: Novasis Pay opera como **gateway técnico**, no como agregador / PayFac.

### Cómo funciona

- **El dinero nunca pasa por cuentas de Novasis**. Cada comercio (merchant) configura **sus propias credenciales** de dLocal Go / Bancard / Pagopar / Dinelco en el panel del ERP (`merchant_provider_config.credenciales_encrypted`).
- El pago del cliente final va **directo** del provider → cuenta bancaria del comercio. Novasis solo orquesta la llamada técnica (intent → checkout → webhook), nunca custodia fondos ajenos.
- El comercio sigue siendo titular de su relación con el provider (KYC, contrato, chargebacks, condiciones de liquidación).

### Cómo cobra Novasis

Dos líneas de ingreso, ambas facturadas **aparte** del flujo de pago (no se descuentan del monto cobrado):

1. **Suscripción mensual** al ERP (ya existe). El módulo Novasis Pay se incluye en el plan o se ofrece como add-on.
2. **Fee por transacción opcional**: monto fijo (ej. 500 Gs / transacción aprobada) o % chico (0.3–0.7%). Se computa sobre `payment_intent.status = approved` y se factura mensualmente al comercio vía `MerchantInvoice` (modelo ya en schema, lógica pendiente).

### Por qué este modelo

- **Cero riesgo regulatorio en Paraguay**: no se requiere licencia EMPE/SPE del BCP porque no se custodia ni transfiere dinero ajeno.
- **Cero exposición a chargebacks**: el contracargo va contra la cuenta del comercio en el provider, no contra Novasis.
- **Cero capital de trabajo**: no hay que pre-financiar payouts ni mantener cuentas liquidadoras.
- **Contabilidad simple**: cada comercio reconcilia con su provider; Novasis solo factura su fee.
- **Encaja con la arquitectura ya construida**: el schema actual (credenciales por merchant) **es** este modelo. No requiere rediseño.

### Trade-offs aceptados

- **Onboarding con más fricción**: cada comercio debe gestionar su propia cuenta dLocal/Bancard antes de poder cobrar. Mitigación: el equipo comercial de Novasis acompaña el alta con el provider (referral).
- **Margen unitario menor** que un PayFac. Mitigación: se compensa con volumen vía base instalada del ERP + venta a terceros (e-commerces) del SDK.
- **Dependencia de que el provider acepte al comercio**: si dLocal rechaza un alta, Novasis no puede ofrecer otro path. Mitigación: multi-provider (Bancard / Pagopar / Dinelco) como fallback.

### Evolución futura (no ahora)

Cuando haya **50+ comercios activos** y volumen probado, se evalúan dos caminos:

- **Acuerdo de revenue-share con providers** (white-label aggregator): el provider da condiciones especiales y comparte margen, sin que Novasis maneje el dinero — sigue siendo modelo B con mejor unit economics.
- **Migración a PayFac (modelo A)**: solo si se decide pedir licencia EMPE en BCP. Es una empresa fintech regulada, decisión estratégica independiente.

### Implicancias inmediatas en producto

- ✅ El panel admin emite las claves de cada comercio (ya implementado).
- ✅ El comercio configura sus propias credenciales del provider (ya implementado).
- 🟡 **Falta**: contador de transacciones aprobadas por merchant para facturación del fee.
- 🟡 **Falta**: lógica de facturación mensual del fee (`MerchantInvoice` ya está en schema).
- 🟡 **Falta**: en el dashboard externo del merchant (Fase 9), mostrar "Este mes: X transacciones · fee a facturar: Y".
- ❌ **No se hace**: KYC propio de Novasis al comercio, payouts, custodia de fondos, conciliación bancaria multi-comercio.

---

## Decisiones de arquitectura confirmadas

### 1. Despliegue — Microservicio independiente

- Repo separado: `payments-gateway`
- DB PostgreSQL propia (no comparte con el ERP)
- Contenedor/proceso propio, dominio propio (`api.pagos.novasis.com`, `pay.novasis.com`)
- El ERP lo consume vía **HTTP REST** como un cliente externo más

**Justificación**: el gateway debe poder venderse/usarse independientemente. Acoplarlo al ERP rompería ese objetivo.

### 2. Multi-tenancy — Merchants propios estilo Stripe

- El gateway tiene su propio concepto de `merchant`
- Cada merchant tiene **API keys propias** (`pk_live_*`, `sk_live_*`)
- El ERP es **un merchant más** que se autentica con sus keys
- Cada merchant configura **sus propias credenciales** de dLocal/Bancard/etc.
- Un e-commerce externo puede registrarse y usar el gateway sin tocar el ERP

### 3. Modelo de checkout — Híbrido (hosted page + SDK drop-in)

- **Hosted page white-label**: `pay.novasis.com/:token`, branding del merchant
- **SDK drop-in embebible**: JS que el merchant pone en su sitio para checkout in-page (estilo Stripe Elements)
- Ambos son white-label, el cliente final no ve "powered by [provider]"

### 4. dLocal Go — Primera integración

- **País inicial**: solo Paraguay (PYG)
- **Arquitectura preparada para multi-país** LATAM (no hardcodear PY/PYG en código de dominio)
- **Métodos**: todos los que dLocal habilite para PY (tarjeta, cash vouchers tipo Aquí Pago / Pago Express, transferencia)
- **Operación inicial**: solo **one-shot** (sin suscripciones, sin split, sin payouts)
- **Credenciales**: cuenta dLocal Go activa
- **Doc oficial**: https://docs.dlocalgo.com/integration-api

### 5. Modelo de datos — Estilo Stripe (intent → attempt → refund)

#### Entidades core

```
merchant
  id, nombre, email, slug, estado (activo/suspendido), pais_default, created_at

merchant_api_key
  id, merchant_id, prefix (pk_live_xxx / sk_live_xxx), hash, scope,
  last_used_at, revocada_at, created_at

merchant_provider_config
  id, merchant_id, provider (dlocal/bancard/pagopar/dinelco),
  modo (sandbox/live), credenciales_encrypted (AES-256-GCM),
  pais, activo, prioridad, created_at, updated_at

customer
  id, merchant_id, email, doc_tipo, doc_numero, nombre, telefono,
  metadata_json, created_at, updated_at
  UNIQUE (merchant_id, email)
  → entidad propia del gateway, permite tokenización futura y reporting

payment_intent
  id, merchant_id, customer_id (nullable), external_reference,
  monto, moneda, pais, descripcion, provider_solicitado,
  estado (created/pending/processing/approved/rejected/expired/refunded),
  metadata_json, idempotency_key (UNIQUE per merchant), expira_at, created_at
  → "intención de cobro". El merchant la crea vía API. Una intent puede tener N intentos.

payment_attempt
  id, intent_id, provider, provider_payment_id, metodo (card/cash/transfer/pix/...),
  estado, response_code, response_message,
  raw_request_json, raw_response_json,
  monto_capturado, fecha_aprobacion, created_at
  → cada intento del cliente final (puede fallar tarjeta y reintentar con cash)

payment_refund
  id, attempt_id, monto, motivo, estado, provider_refund_id,
  raw_request_json, raw_response_json, created_at

webhook_event_in
  id, provider, payload_json, signature, headers_json, processed, error,
  attempt_id (nullable, resuelto post-procesamiento), received_at
  → webhooks que RECIBIMOS del provider (idempotencia + replay)

webhook_endpoint
  id, merchant_id, url, secret_hmac, eventos_suscritos[], activo, created_at

webhook_event_out
  id, endpoint_id, evento, payload_json, intentos, ultimo_intento_at,
  proximo_intento_at, estado (pending/delivered/failed), created_at
  → webhooks que NOSOTROS enviamos al merchant (al ERP, e-commerce, etc.)

checkout_session
  id, intent_id, token_publico (URL-safe, 43 chars), expira_at,
  ui_config_json, return_url, cancel_url, estado, created_at
  → sesión del hosted page o del SDK drop-in
```

#### Decisiones sobre el modelo

- **5.1 Separación intent → attempt → refund**: ✅ Adoptado (modelo Stripe). Permite reintentos con distintos métodos sin perder trazabilidad del "qué se quería cobrar".
- **5.2 Entidad customer propia**: ✅ Adoptado. El gateway guarda email/doc/nombre/teléfono por merchant. Habilita tokenización futura y reporting cliente↔merchant.
- **5.3 Routing entre providers**: ✅ **El merchant elige al crear el intent** (`provider_solicitado: "dlocal"`). El gateway no decide automáticamente. (Routing inteligente queda fuera de fase 1.)
- **5.4 Guardar JSON crudo de cada llamada al provider**: ✅ Adoptado. `raw_request_json` y `raw_response_json` en `payment_attempt` y `payment_refund`. Útil para debug, disputas y auditoría. Costo de storage asumido.

---

## Decisiones pendientes

(se irán llenando pregunta por pregunta)

- [x] 6 — Integración con el ERP (webhooks + polling + SDK)
- [x] 7 — Modelo de auth (API, hosted page, SDK drop-in, dashboard)
- [x] 8 — Operativa: idempotencia, reintentos, conciliación, comisiones, FX
- [x] 9 — Stack técnico del microservicio
- [x] 10 — Plan de fases / roadmap de implementación

### 7. Autenticación

#### 7.1 API REST server-to-server

- Header `Authorization: Bearer sk_live_*` (o `sk_test_*` en sandbox).
- **Solo desde backend del merchant**, nunca expuesta al cliente final.
- **Scopes** por key: `full` (todo) o `restricted` (ej. solo crear intents, sin refunds).
- **Rate limiting** por API key (default 100 req/s, ajustable por merchant).
- **Rotación**: el merchant crea nuevas keys y revoca viejas desde el dashboard sin downtime.

#### 7.2 Hosted page `pay.novasis.com/:token`

- `checkout_session.token_publico`: URL-safe, 43 chars (`crypto.randomBytes(32).toString('base64url')`), no enumerable, con expiración.
- **Un solo uso**: al pagarse o expirar, queda inutilizable.
- **Regenerable**: el merchant puede llamar `POST /checkout_sessions/:id/regenerate` si el cliente perdió el link (invalida el anterior, emite uno nuevo).
- **Anti-brute-force**: máximo N reintentos de pago fallidos en una misma sesión antes de invalidarla (ej. 5).

#### 7.3 SDK drop-in en browser — patrón publishable key + client_secret

1. Backend del merchant llama al gateway con `sk_live_*` y crea `payment_intent` → recibe `client_secret` (token corto con scope limitado a ese intent específico).
2. Backend pasa al frontend: `pk_live_*` (pública, identifica al merchant) + `client_secret` (autoriza operar solo sobre ese intent).
3. SDK drop-in en el navegador usa esos dos para tokenizar tarjeta, mostrar métodos disponibles y confirmar el pago.
4. `pk_live_*` por sí sola NO puede crear intents ni leer otros pagos: solo confirmar el intent que ya tiene su `client_secret`.

Modelo equivalente al de Stripe.

#### 7.4 Dashboard de gestión — Híbrido (Opción C)

- **Gateway expone solo API REST**, sin UI propia.
- **Fase 1 (ahora)**: módulo "Pasarela de Pagos" en `pos-ventas`. Reutiliza auth, permisos, componentes `_standards/`. Sirve a los merchants que son empresas del ERP.
- **Fase 2 (cuando llegue un merchant externo real)**: dashboard standalone (`admin.pagos.novasis.com`) como app React aparte, consumiendo la misma API del gateway.
- **Regla crítica**: la API del gateway NO debe asumir quién la consume. Nada de endpoints `/erp/...` ni lógica especial para el ERP. Solo `/v1/merchants/me/...` autenticada con `sk_live_*`, igual para todos.

### 8. Operativa

#### 8.1 Idempotencia de creación de intents

- Header opcional `Idempotency-Key: <uuid>` en `POST /v1/payment_intents`.
- El gateway guarda mapeo `(merchant_id, idempotency_key) → intent_id` por **24h**.
- Si llega de nuevo la misma key dentro de la ventana: devuelve el intent original sin crear uno nuevo.
- Si no se envía la key: se crea siempre un intent nuevo (no obligatoria, pero **fuertemente recomendada** en docs).

#### 8.2 Reintentos del gateway hacia el provider — Opción B

- **Solo errores transitorios**: timeout, 502, 503, 504, connection reset.
- **Hasta 3 intentos** con backoff: 200ms, 1s, 5s.
- **Errores funcionales NO se reintentan**: tarjeta rechazada, fondos insuficientes, datos inválidos, 4xx en general (salvo 408/429 que sí).
- Cada intento se registra en `payment_attempt.raw_request/response_json` con tag de intento.

#### 8.3 Conciliación con liquidaciones del provider — Fase escalonada

- **Fase 1**: NO conciliación automática (Opción B). El gateway solo registra el pago. El merchant concilia mirando su extracto bancario.
- **Fase 2**: importación automática de reportes de liquidación del provider (Opción A). Tablas:
  - `liquidacion`: id, provider, fecha_liquidacion, monto_bruto, monto_comisiones, monto_neto, banco_cuenta_id, archivo_origen
  - `liquidacion_item`: id, liquidacion_id, payment_attempt_id, monto, comision, neto
- Cuando esté Fase 2, el webhook `payment_intent.settled` (nuevo evento) le avisa al merchant que su pago se liquidó.

#### 8.4 Pricing del gateway — Modelo flexible (B + C + D)

El gateway cobra al merchant por usar el servicio (además de lo que cobra el provider). Pricing configurable por merchant, combinando:

- **B — Fee fijo por transacción aprobada** (ej. Gs. 500 por cobro).
- **C — Fee porcentual sobre el monto aprobado** (ej. 0.5%).
- **D — Plan mensual / suscripción** (ej. Gs. 200.000/mes).

Tablas necesarias:

```
merchant_pricing_plan
  id, merchant_id, nombre, vigente_desde, vigente_hasta,
  cuota_mensual, moneda_cuota,
  fee_fijo_por_tx, fee_porcentual_tx, moneda_tx,
  incluye_n_tx_por_mes (nullable, para planes con cupo),
  fee_exceso_tx (cuando se pasa del cupo),
  activo

merchant_invoice
  id, merchant_id, periodo (YYYY-MM), monto_cuota, monto_tx_fees,
  cantidad_tx, monto_total, estado (pendiente/pagada/vencida),
  fecha_emision, fecha_vencimiento, fecha_pago, payment_intent_id (nullable, self-billing)

merchant_invoice_item
  id, invoice_id, tipo (cuota_mensual / tx_fee_fijo / tx_fee_porcentual / exceso_cupo),
  descripcion, cantidad, monto_unitario, monto_total
```

**Self-billing**: el gateway puede cobrarse a sí mismo usando su propia API (el merchant "Gateway-Admin" cobra al merchant cliente). Cierra el loop.

#### 8.5 Conversión de moneda — Configurable por `merchant_provider_config`

Campo nuevo: `merchant_provider_config.estrategia_moneda` ∈ `{ rechazar_otras, delegar_provider, congelar_propia }`.

- **`rechazar_otras`**: si el intent llega en moneda distinta a la moneda de liquidación del provider configurado → 400. Útil para merchants que quieren control absoluto.
- **`delegar_provider`**: se envía la moneda original al provider, él hace la conversión y liquida en su moneda. Es lo que dLocal soporta nativo.
- **`congelar_propia`**: el gateway llama a un servicio de cotizaciones al crear el intent, congela tasa en `payment_intent.fx_rate_locked` y convierte antes de enviar al provider. Requiere un servicio de FX (BCB / dLocal FX / proveedor externo) y maneja el riesgo cambiario.

Default sugerido: `delegar_provider` (es el más simple y dLocal lo soporta nativo). El merchant puede cambiarlo por provider.

### 9. Stack técnico

#### 9.1 Backend del gateway

- **NestJS + Prisma + PostgreSQL** (mismo stack del ERP).
- Aprovecha conocimiento del equipo, reusa patrones, hace natural el SDK TypeScript compartido.
- Estructura modular: `src/merchants/`, `src/intents/`, `src/attempts/`, `src/refunds/`, `src/webhooks-in/`, `src/webhooks-out/`, `src/checkout-sessions/`, `src/providers/dlocal/`, `src/providers/bancard/`, `src/billing/`.

#### 9.2 Cola de jobs — BullMQ + Redis

- `@nestjs/bullmq` para jobs asíncronos.
- Colas iniciales:
  - `webhooks-out` — entrega webhooks salientes a merchants con backoff.
  - `provider-retry` — reintentos transitorios al provider (timeouts, 5xx).
  - `webhooks-in-process` — procesa eventos del provider fuera del request HTTP.
  - `liquidaciones` (Fase 2) — importación periódica de reportes.
- Redis dedicado para el gateway (no compartir con el del ERP si existe).

#### 9.3 Infraestructura

- VPS / servidor propio (mismo Docker host del ERP en fase 1).
- Containers separados: `payments-gateway-api`, `payments-gateway-worker` (BullMQ workers), `payments-gateway-db`, `payments-gateway-redis`, `payments-checkout-web` (hosted page).
- Reverse proxy (Nginx/Caddy/Traefik) ya existente del ERP enruta los subdominios:
  - `api.pagos.novasis.com` → gateway-api
  - `pay.novasis.com` → checkout-web
  - `js.pagos.novasis.com` → CDN/static del SDK drop-in

#### 9.4 Frontend hosted page + SDK drop-in

- **Hosted page**: React + Vite (igual stack que pos-ventas), SPA servida en `pay.novasis.com`. Reutiliza componentes de `pos-ventas/src/components/_standards/` empaquetados como librería interna.
- **SDK drop-in**: paquete TypeScript compilado con Vite library mode (UMD + ESM). Servido desde `js.pagos.novasis.com/v1/dropin.js` con cache largo + versionado.
- **SDK server-side**: paquete npm `@novasis/payments-sdk` (Node, TS), publicado en npm privado o registry interno.

#### 9.5 Encriptación de credenciales

- Variable de entorno `MASTER_ENCRYPTION_KEY` (32 bytes base64, AES-256-GCM).
- Encripta: `merchant_provider_config.credenciales_encrypted`, `webhook_endpoint.secret_hmac`, `merchant_api_key.hash` (este último es hash bcrypt, no encriptado reversible).
- Documentar **proceso de rotación**: script `npm run rotate-master-key` que re-encripta todos los registros leyendo `OLD_MASTER_ENCRYPTION_KEY` y `NEW_MASTER_ENCRYPTION_KEY`.
- En `.env` de producción: nunca commitear, usar `.env.production.local` o secretos del orquestador.

#### 9.6 Observabilidad — stack limpio en el gateway

- **Logging estructurado**: Pino (JSON) con correlation ID por request, ID de merchant, ID de intent.
- **Errores**: Sentry (DSN propio del gateway, no compartido con el ERP).
- **Métricas**: Prometheus exporter (`/metrics`) con counters de intents creados/aprobados/rechazados por provider/país/método, latencia P50/P95/P99 por endpoint, tasa de error por provider.
- **Dashboards**: Grafana con dashboards por provider y por merchant.
- **Alertas**: tasa de aprobación cae >20%, cola de webhooks-out crece sin parar, errores 5xx del provider sostenidos.
- **Audit log**: tabla `audit_log` con cambios a `merchant_provider_config`, creación/revocación de API keys, cambios de pricing plan.

### 10. Roadmap de implementación

#### Premisas

- **Equipo**: 1 desarrollador (el dueño del proyecto).
- **Meta declarada**: MVP "vendible a terceros" (Opción C de 10.1) — gateway completo con SDK drop-in, onboarding self-service, billing.
- **Código Bancard existente del ERP**: se deja intacto. El gateway nuevo arranca **solo con dLocal Go**. Bancard se reescribe dentro del gateway en fase posterior si vale la pena.
- **Sin Compra Asistida en el primer release**: la integración del flujo CA → cobro vive en pos-ventas como consumidor del gateway, no condiciona la fecha de salida del gateway.

#### Estimación honesta

Con 1 dev a tiempo completo, alcanzar MVP C **realista**: ~3-4 meses de trabajo continuo. Variantes:
- Si en algún punto urge cobrar Compras Asistidas, se puede entregar un release intermedio "ERP-internal" (equivalente a Opción A) en ~4-6 semanas, sin perder camino hacia C.
- Si se mete a un segundo dev en paralelo en la hosted page + SDK, se baja a ~2-2.5 meses.

El roadmap está pensado para que cada fase **deje algo funcionando en producción**, no para entregar todo de una.

---

#### Fase 0 — Setup (semana 1)

**Objetivo**: repo, infra base, herramientas, listo para escribir lógica de dominio.

- Crear repo `payments-gateway` (NestJS + Prisma).
- Docker Compose local: postgres + redis.
- Estructura modular base (módulos vacíos, eslint, prettier, vitest/jest).
- `prisma/schema.prisma` inicial vacío + migración base.
- CI mínimo (lint + tests).
- Decidir nombre de producto / marca / dominios definitivos.
- Registrar dominios: `api.pagos.X`, `pay.X`, `js.pagos.X`.
- Subdominios apuntando al servidor con Nginx/Caddy preparado.

**Entregable**: `git clone && docker compose up && curl localhost:3000/health → ok`.

---

#### Fase 1 — Core del gateway sin proveedor real (semanas 2-4)

**Objetivo**: modelo de datos completo + auth + endpoints CRUD sin ningún provider integrado.

- Migraciones Prisma: `merchant`, `merchant_api_key`, `merchant_provider_config`, `customer`, `payment_intent`, `payment_attempt`, `payment_refund`, `webhook_endpoint`, `webhook_event_in`, `webhook_event_out`, `checkout_session`, `audit_log`.
- Servicio de encriptación AES-256-GCM con `MASTER_ENCRYPTION_KEY` y script de rotación.
- Auth guard `Authorization: Bearer sk_*` con scopes.
- Rate limiter por API key.
- Endpoints (todos `/v1/...`):
  - `POST /merchants` (admin del gateway), `GET /merchants/me`
  - `POST /api-keys`, `GET /api-keys`, `DELETE /api-keys/:id`
  - `POST /customers`, `GET /customers`, `GET /customers/:id`
  - `POST /payment-intents` (con `Idempotency-Key`), `GET /payment-intents/:id`, `GET /payment-intents` (lista filtrada)
  - `POST /checkout-sessions`, `GET /checkout-sessions/:id`
  - `POST /webhook-endpoints`, `GET /webhook-endpoints`
  - `POST /provider-configs`, `GET /provider-configs` (con `credenciales_encrypted`)
- Tests de integración con un "provider mock" (no dLocal todavía).

**Entregable**: gateway que recibe `POST /v1/payment-intents` y devuelve un intent en `created` sin tocar provider real. Auth y multi-tenancy funcionando.

---

#### Fase 2 — Adapter dLocal Go (semanas 5-6)

**Objetivo**: el primer intent real se aprueba contra dLocal sandbox.

- Interfaz `IPaymentProvider` con métodos `createPayment`, `getPayment`, `refund`, `verifyWebhook`.
- Implementación `DLocalGoProvider` siguiendo https://docs.dlocalgo.com/integration-api.
- Endpoint `POST /payment-intents/:id/confirm` (server-side, lo llama el merchant).
- Endpoint público `POST /checkout-sessions/:token/confirm` (lo llama la hosted page).
- BullMQ + worker para reintentos transitorios al provider (Opción B de 8.2).
- Endpoint `POST /webhooks/dlocal` (público, verifica firma, encola para procesar).
- Worker `webhooks-in-process` que actualiza `payment_attempt` y dispara evento interno.
- Tests E2E contra sandbox de dLocal.

**Entregable**: `curl POST /v1/payment-intents` con tarjeta sandbox → intent pasa a `approved` → webhook recibido y procesado.

---

#### Fase 3 — Webhooks salientes + SDK server-side (semana 7)

**Objetivo**: el merchant se entera de los pagos sin pollear.

- Servicio de firma HMAC-SHA256 de webhooks.
- Worker `webhooks-out` con backoff exponencial (1m, 5m, 30m, 2h, 12h, 24h) + dead letter.
- Endpoint `GET /webhook-events/:id` y `POST /webhook-events/:id/retry` (debug del merchant).
- SDK `@novasis/payments-sdk` (Node + TypeScript):
  - `client.intents.create({...})`, `client.intents.retrieve(id)`, etc.
  - `client.webhooks.verify(rawBody, signature, secret)`
- Publicar SDK en npm privado o registry interno.
- Tests del SDK contra el gateway.

**Entregable**: el gateway envía webhooks firmados; un script de prueba los recibe y verifica firma con el SDK.

---

#### Fase 4 — Hosted page (semanas 8-10)

**Objetivo**: `pay.novasis.com/:token` funcionando con dLocal.

- Repo/folder `payments-checkout-web` (React + Vite).
- Empaquetar componentes reutilizables de `pos-ventas/src/components/_standards/` como librería interna (npm link o monorepo).
- Páginas:
  - `/:token` — resumen del cobro + selector de método.
  - Sub-flujos por método: tarjeta (Smart Fields de dLocal), cash voucher (mostrar código + instrucciones), transferencia.
  - `/:token/success`, `/:token/error`, `/:token/expired`.
- Branding configurable por merchant (logo, colores) desde `checkout_session.ui_config_json`.
- Tokenización de tarjeta vía Smart Fields del provider (no toca tu backend).
- i18n inicial: español PY.

**Entregable**: cliente final paga end-to-end con tarjeta de prueba de dLocal desde la hosted page.

---

#### Fase 5 — Módulo "Pasarela" en pos-ventas (semanas 11-12)

**Objetivo**: el ERP es el primer merchant productivo.

- Onboarding: el ERP se registra como merchant en el gateway (`POST /merchants`) — manual al inicio.
- Módulo `pos-ventas/src/pages/pasarela/`:
  - Dashboard con últimos cobros, tasa de aprobación, monto del mes.
  - Gestión de API keys (listar, crear, revocar).
  - Configuración de providers (form para guardar credenciales dLocal).
  - Configuración de webhook endpoints (ver secret, regenerar).
  - Listado de payment_intents con filtros (estado, fecha, monto).
  - Detalle de un intent: timeline, attempts, refunds, raw JSON colapsable.
- Permisos en el ERP: `PASARELA_VIEW`, `PASARELA_ADMIN`, `PASARELA_REFUND`.
- Aplica UI Standards de `docs/ui-standards.md` (MonedaInput, EmptyState, ScreenGuia, fechas vía `utils/fecha.js`).

**Entregable**: usuario del ERP gestiona la pasarela desde pos-ventas sin tocar API directamente.

---

#### Fase 6 — Integración Compra Asistida → Gateway (semanas 13-14)

**Objetivo**: cerrar el caso de uso original del plan Bancard que se descartó.

- En `smartfactvoice-backend/src/compra-asistida/`:
  - Botón "Generar link de pago" en CA `cotizado` → crea intent + checkout_session vía SDK.
  - Webhook listener `POST /webhooks/payments-gateway` en el ERP que verifica HMAC y avanza estado.
  - Listener para `payment_intent.approved` → `compra_asistida.estado = pago_recibido`.
- Frontend pos-ventas: modal "Compartir link" con copy/WhatsApp/email.
- Migración del enum `compra_asistida_estado` (la que estaba en fase 2I del plan viejo).
- Tests: T01–T10 del plan viejo, adaptados.

**Entregable**: operador genera link desde una CA, cliente paga en `pay.novasis.com/:token`, CA pasa a `pago_recibido` automáticamente.

---

#### Fase 7 — SDK drop-in browser (semanas 15-17)

**Objetivo**: cualquier sitio puede embeber el checkout in-page.

- Repo/folder `payments-dropin-js`.
- Vite library mode → bundle UMD + ESM.
- API pública:
  ```js
  const payments = Payments('pk_live_xxx');
  const dropin = payments.dropin({ clientSecret: 'cs_xxx', container: '#pay' });
  dropin.on('success', ...); dropin.on('error', ...);
  ```
- Tokenización segura (la tarjeta nunca pasa por el merchant ni por tu gateway sin tokenizar).
- Hosting en `js.pagos.novasis.com/v1/dropin.js` con versionado.
- Demo HTML de muestra.
- Docs públicas de integración.

**Entregable**: HTML estático con 10 líneas de JS cobra contra el gateway.

---

#### Fase 8 — Billing al merchant (semanas 18-20)

**Objetivo**: cobrar a los merchants por usar el gateway.

- Tablas `merchant_pricing_plan`, `merchant_invoice`, `merchant_invoice_item`.
- CRUD de planes (admin del gateway).
- Job mensual: calcula consumo de cada merchant + emite `merchant_invoice`.
- Self-billing: la invoice del merchant genera un `payment_intent` interno cobrado contra el merchant administrador del gateway.
- Vista en el dashboard del merchant: historial de invoices, descarga PDF, estado de pago.
- Webhook `merchant_invoice.created` / `merchant_invoice.paid`.

**Entregable**: el primer cierre de mes emite invoices automáticas.

---

#### Fase 9 — Onboarding self-service de merchants externos (semanas 21-22)

**Objetivo**: un tercero se registra solo, sin tocar al ERP.

- Repo nuevo `payments-merchant-dashboard` (React + Vite) en `dashboard.pagos.novasis.com`.
- Signup/login propio (email + password, magic link, o OAuth).
- Verificación de email + KYC mínimo (datos fiscales, cuenta bancaria de liquidación).
- Reutiliza la misma API del gateway que usa el módulo del ERP.
- Wizard de onboarding: crear primer merchant, configurar dLocal, generar API key, ver "Hello World" en docs.

**Entregable**: tercero externo se registra, configura dLocal, recibe su primer cobro de prueba — sin contactarte.

---

#### Fase 10 — Hardening y producción (semanas 23-24)

- Observabilidad completa (Pino + Sentry + Prometheus + Grafana + alertas).
- Pen test interno básico.
- Documentación pública de API (estilo Stripe docs) — al menos OpenAPI + ejemplos.
- Backup automatizado de DB + plan de DR.
- Plan de rotación de `MASTER_ENCRYPTION_KEY` probado.
- Compliance check mínimo: PCI SAQ-A si solo redirigís a Smart Fields del provider.

**Entregable**: gateway listo para tomar tráfico real de terceros pagantes.

---

#### Fases futuras (no comprometidas)

- **F11** — Adapter Bancard (reescribir desde el código existente del ERP).
- **F12** — Adapter Pagopar.
- **F13** — Adapter Dinelco.
- **F14** — Conciliación automática con liquidaciones (Fase 2 de 8.3).
- **F15** — Suscripciones / cobros recurrentes con tokenización.
- **F16** — Payouts.
- **F17** — Multi-país real (AR, BR, UY, etc.).
- **F18** — Routing inteligente entre providers.
- **F19** — Antifraude propio (reglas + scoring).

---

## Resumen ejecutivo

| Bloque | Decisión |
|---|---|
| Despliegue | Microservicio independiente, repo y DB propios |
| Multi-tenancy | Merchants propios con API keys `sk_*` / `pk_*` |
| Checkout | Hosted page white-label + SDK drop-in |
| Primer provider | dLocal Go (Paraguay, one-shot, todos los métodos disponibles) |
| Modelo de datos | Stripe-like: intent → attempt → refund + customer + checkout_session |
| Notificación | Webhooks HMAC + polling + SDK Node oficial |
| Auth | Bearer sk_* (API), token público (hosted), pk_* + client_secret (drop-in) |
| Dashboard | Módulo en pos-ventas ahora, standalone después |
| Operativa | Idempotency-Key, reintentos transitorios, billing flexible (cuota + tx) |
| Stack | NestJS + Prisma + PostgreSQL + BullMQ/Redis + Vite React |
| Encriptación | AES-256-GCM con `MASTER_ENCRYPTION_KEY` rotable |
| Observabilidad | Pino + Sentry + Prometheus + Grafana (limpio en el gateway) |
| MVP target | Opción C (vendible a terceros) — ~3-4 meses solo dev |
| Bancard interno del ERP | Se deja intacto, no se migra al gateway en fase inicial |

### 6. Integración con merchants (ERP y terceros)

#### 6.1 Mecanismo de notificación — Webhooks + polling

- **Primario**: webhooks salientes HTTP POST firmados con HMAC-SHA256 usando `webhook_endpoint.secret_hmac`.
- **Respaldo**: el merchant puede hacer `GET /payment_intents/:id` en cualquier momento para consultar estado.
- **Reintentos del webhook**: backoff exponencial (ej. 1m, 5m, 30m, 2h, 12h, 24h) con dead-letter al agotarse.
- **Idempotencia del receptor**: cada `webhook_event_out.id` es único; el merchant debe deduplicar por ese ID.

#### 6.2 Eventos publicados

- `payment_intent.created`
- `payment_intent.processing`
- `payment_intent.approved` ← evento crítico, el que dispara el avance de estado en el ERP
- `payment_intent.rejected`
- `payment_intent.expired`
- `payment_refund.created`
- `payment_refund.approved`

#### 6.3 Formato del payload

Payload **mínimo** con IDs + estado. El merchant hace `GET` para detalles. Reduce acoplamiento al schema y mantiene datos siempre frescos.

```json
{
  "id": "evt_01HXYZ...",
  "type": "payment_intent.approved",
  "created_at": "2026-06-06T14:30:00Z",
  "data": {
    "payment_intent_id": "pi_01HXYZ...",
    "merchant_id": "mer_01HXYZ...",
    "external_reference": "CA-2026-0123",
    "estado": "approved"
  }
}
```

Headers del request:
- `X-Payments-Signature: t=<timestamp>,v1=<hmac_sha256>`
- `X-Payments-Event-Id: evt_01HXYZ...`
- `User-Agent: PaymentsGateway-Webhook/1.0`

#### 6.4 SDK oficial TypeScript

Paquete `@novasis/payments-sdk` (npm privado o monorepo) que:
- Envuelve la API REST (`paymentsClient.intents.create({...})`)
- Verifica firmas HMAC de webhooks entrantes (`paymentsClient.webhooks.verify(rawBody, signature, secret)`)
- Tipa todas las entidades y eventos
- Se publica versionado, lo usa el ERP y cualquier merchant interno

---

## Convenciones de schema (Prisma + PostgreSQL)

Reglas no negociables para todo modelo nuevo o modificado en `novasis-pay/prisma/schema.prisma`:

### 1. Enums tipados — no strings libres
- Todo campo con dominio cerrado se modela como `enum` Prisma (mapeado a tipo PostgreSQL nativo y a tipo TS).
- En DTOs se valida con `@IsEnum(EnumDelPrisma)` y se documenta con `@ApiProperty({ enum: ... })` para que Swagger lo refleje.
- Si el dominio puede crecer (ej. tipos de documento por país), incluir miembro `other` como escape hatch en vez de degradar a `String`.
- Strings libres quedan solo para: nombres propios, descripciones, IDs externos opacos, JSON metadata.

### 2. Índices explícitos por patrón de query
- Por cada query previsto del módulo, declarar un `@@index` (a menos que esté ya cubierto por `@@unique` o por el PK).
- Patrones obligatorios:
  - `@@index([merchantId, createdAt])` — listados paginados por cursor.
  - `@@index([merchantId, status])` — filtros por estado en listados.
  - Orden de columnas: selectividad descendente (la más restrictiva primero).
- Para tablas con muchos `findFirst` por `(scope, externalId)` (ej. webhooks por provider+providerPaymentId), índice compuesto explícito.
- Índices parciales (`WHERE`) se anotan como TODO si Prisma aún no los soporta en el modelo y se aplican vía migration manual cuando aporten.

### 3. Migraciones
- Cada cambio de schema → migration nombrada (`pnpm prisma migrate dev --name add_customer_doc_type_enum`).
- Nunca `db push` en main; solo en exploración local.
- PR que cambia schema debe incluir tanto el `.prisma` como el SQL generado.

---

## Documentación de API (Swagger / OpenAPI)

**Decisión:** toda la API REST del gateway se documenta con **Swagger / OpenAPI 3** usando `@nestjs/swagger`. Es requisito de aceptación de cada módulo, no opcional ni "para después".

### Cobertura obligatoria por endpoint

Para cada endpoint público se documenta:

1. **Tag y resumen** — agrupado por recurso (`Merchants`, `API Keys`, `Customers`, `Payment Intents`, `Checkout Sessions`, `Refunds`, `Webhooks`, `Admin`).
2. **Auth scheme** — `bearerAuth` (API key `sk_*` / `pk_*`) o `adminAuth` (token `adm_*`), declarados con `addBearerAuth` en el bootstrap.
3. **Headers esperados** — `Authorization`, `Idempotency-Key` (cuando aplica), `X-Payments-Signature` (en webhooks salientes/entrantes).
4. **Path params + query params** — con tipo, descripción, ejemplo y si son requeridos.
5. **Request body** — DTO decorado con `@ApiProperty({ description, example, required })`. Incluir ejemplo completo realista en PYG.
6. **Respuestas** — `@ApiResponse` para cada código posible:
   - `2xx` con DTO de salida y ejemplo
   - `400` ValidationPipe (formato estándar)
   - `401` auth faltante/invalida
   - `403` scope insuficiente / merchant inactivo
   - `404` recurso no encontrado
   - `409` conflicto (slug duplicado, idempotency mismatch)
   - `429` throttling
   - `5xx` errores del provider downstream (en endpoints que tocan provider)
7. **Errores tipados** — formato de error uniforme documentado una sola vez como schema reutilizable (`ApiErrorResponse { error: { code, message, details? } }`).
8. **Idempotencia** — endpoints que aceptan `Idempotency-Key` lo declaran explícitamente con descripción de ventana (24h) y comportamiento ante repetición.
9. **Webhooks** — los eventos salientes se documentan como **schemas tipados** (`PaymentIntentSucceededEvent`, etc.) aunque no sean endpoints. Se exponen en una sección "Webhooks" del Swagger usando `@ApiExtraModels`.
10. **Versionado** — todas las rutas viven bajo `/v1`. Cambios breaking → `/v2`. El Swagger declara el `servers` con la base URL por entorno.

### Configuración técnica

- `SwaggerModule.setup("docs", app, document)` expone UI en `/docs` (solo en `NODE_ENV !== "production"` o detrás de basic-auth en prod).
- `SwaggerModule.setup("docs-json", ...)` expone el JSON crudo para generar SDKs (`openapi-generator` o `openapi-typescript`).
- DTOs de entrada/salida son la fuente de verdad: nunca documentar "a mano" un response que no esté tipado.
- En CI se valida que el OpenAPI generado sea válido (`@redocly/cli lint`).
- Se commitea snapshot de `openapi.json` en `docs/openapi/` para diff visible en PRs.

### Convenciones de naming en el OpenAPI

- `operationId`: `<resource><Action>` → `paymentIntentsCreate`, `paymentIntentsRetrieve`, `paymentIntentsList`.
- Schemas: PascalCase singular (`PaymentIntent`, `CreatePaymentIntentDto`, `PaymentIntentList`).
- Ejemplos: usar IDs realistas con prefijo (`pi_01HXYZ...`) y montos PYG enteros sin decimales en el ejemplo principal.

### Documentación complementaria (Markdown)

Aparte del Swagger autogenerado, mantenemos en `novasis-pay/docs/`:

- `api-overview.md` — autenticación, idempotencia, errores, paginación, rate limits.
- `webhooks.md` — firma HMAC, retries, replay protection, catálogo de eventos.
- `integration-guides/` — guías paso a paso por caso de uso (one-shot con redirect, hosted checkout con SDK drop-in, integración server-to-server).
- `changelog.md` — cambios de API por versión.

---

## Estado de implementación (2026-06-07)

Repo: `/var/www/html/proyectos/novasis-pay`

### ✅ Implementado

- **Infra base**: NestJS 10 + Prisma 5 + PostgreSQL 16 + Redis 7 (BullMQ) corriendo en `docker-compose.yml` (Postgres `:5435`, Redis `:6381`). Logger pino, throttler global, raw-body parser para webhooks.
- **Swagger**: `/docs` + `/docs-json` con `bearerAuth` (sk_/pk_) y `adminAuth` (adm_). `ApiErrorResponse` reutilizable.
- **Schema Prisma + migraciones**: 15 modelos (merchant, api_key, provider_config, customer, payment_intent/attempt/refund, checkout_session, webhook_endpoint/event_out/event_in, pricing_plan, invoice/item, audit_log). Enums para todos los dominios cerrados. Índices explícitos `(merchantId, createdAt)` / `(merchantId, status)` / `(merchantId, active)` en todas las tablas con queries por merchant.
- **Crypto**: `EncryptionService` AES-256-GCM v1 + `signWebhook` / `verifyWebhook` con replay protection (5 min). Specs unitarios pasando.
- **Auth**: `ApiKeyGuard` (bcrypt rounds=12), `AdminGuard` (token bearer estático), `RestrictedScopeGuard` para scope `full`. Decorator `@MerchantAuth()`.
- **Merchants** (admin) + endpoint self (`GET /merchants/me`). CRUD con paginación cursor.
- **API Keys** (admin): create devuelve `secret` una sola vez. List + revoke.
- **Customers** (merchant scope): CRUD con `CustomerDocType` enum, unique email/document por merchant.
- **Provider Configs** (merchant scope `full`): credentials encriptadas AES-256-GCM, nunca devueltas (`hasCredentials: bool`). Unique `(merchantId, provider, country, mode)`.
- **Payment Intents**: create con Idempotency-Key, list, retrieve, **confirm** (rutea adapter → crea PaymentAttempt → actualiza intent → emite webhook).
- **Checkout Sessions**: 1:1 con intent. `publicToken` + `clientSecret` (devuelto una vez). Endpoint público `/v1/public/checkout-sessions/:publicToken` sin auth.
- **Webhook Endpoints** (merchant scope `full`): `whsec_<64hex>` generado y encriptado. Suscripción a `eventTypes[]`.
- **Webhook Events Out**: catálogo de tipos, `WebhookEventService.emit()`, processor BullMQ con concurrency=8, HMAC-SHA256 firmado, retry exponencial `[1, 5, 30, 120, 360, 720, 1440]` min (8 intentos), estados pending/delivered/failed/dead.
- **Webhook Events In**: receptor `POST /webhooks/in/:provider/:providerConfigId` con `@RawBody()`, validación HMAC por adapter, dedup por signature, update de attempt + intent, emisión de webhook saliente.
- **Provider adapter**: interfaz `ProviderAdapter` (createPayment, getPayment, refund, parseWebhook). **dLocal Go** implementado (axios + X-Login/X-Trans-Key + status/method maps + verificación HMAC).
- **Provider Router**: selecciona config por `(merchantId, country, requestedProvider?, mode?)` ordenado por priority+createdAt, decripta credentials, construye adapter.
- **Refunds**: create (full o parcial), cálculo de remaining, transición intent → `partially_refunded` / `refunded`, webhook emit.
- **Admin Events**: list/retrieve outbound, list inbound, retry manual (reencola en BullMQ).
- **OpenAPI snapshot + lint**: `scripts/snapshot-openapi.ts` boota AppModule headless y dumpea `docs/openapi/openapi.json` (serverUrl `https://api.novasis.io`). `scripts/check-openapi.ts` falla CI si está stale. `redocly.yaml` con reglas bajo `apis: novasis-pay:` (security-defined off, no-invalid-schema-examples off por nullable DTOs).
- **Hardening hosted checkout (backend)**: extraído `src/app.bootstrap.ts` con `applyAppMiddleware(app)` reutilizado por main.ts y test-app.ts. Helmet (frameguard DENY + referrer no-referrer siempre, CSP/HSTS solo prod). CORS configurable vía `PUBLIC_CORS_ORIGINS`. Throttle por endpoint: GET `/v1/public/checkout-sessions/:publicToken` 30/min, POST `/confirm` 5/min.
- **Endpoint público confirm**: `POST /v1/public/checkout-sessions/:publicToken/confirm` con DTO `ConfirmPublicCheckoutSessionDto` (email/name/docNumber/phone/country con caps). `confirmByPublicToken()` rutea adapter, devuelve `{status, redirectUrl?}`. Anti-enumeración: `loadPublicSessionOrThrow()` devuelve 404 uniforme para token desconocido / expirado / merchant inactivo / sesión locked. `bumpFailed()` lockea sesión tras `PUBLIC_MAX_FAILED_ATTEMPTS = 5`.
- **Testing backend**:
  - Unit specs (vitest + swc): `EncryptionService`, `hmac.util`, `id.util`, `DlocalAdapter.parseWebhook`. **21 tests pasando**.
  - E2E (vitest + supertest + DB real + fake adapter override): `payment-intent.e2e-spec.ts`, `refund.e2e-spec.ts`, `webhook-outbound.e2e-spec.ts`, `webhook-inbound.e2e-spec.ts`, `public-checkout.e2e-spec.ts` (load safe info, 404 unknown/inactive/expired, confirm approved+redirectUrl, lockout tras 5 intentos, reject invalid email, security headers). **33 tests pasando**.
- **CI mejorado** (`.github/workflows/ci.yml`): Postgres + Redis services, `prisma migrate deploy`, `pnpm test`, `pnpm test:e2e`, `pnpm openapi:check`, `pnpm openapi:lint`.

### ✅ Hosted Checkout SPA (`/var/www/html/proyectos/novasis-pay-checkout`)

Repo separado, Vite + React 19 + TypeScript + MUI v7 + react-router-dom v7.

- **Seguridad**: `<meta name="referrer" content="no-referrer">` + `<meta name="robots" content="noindex">`. Fetch nativo con AbortController (timeout 12s), `credentials: omit`, `cache: no-store`, `redirect: manual`. Sin libs externas de HTTP (sin axios). `CheckoutApiError` tipado.
- **Estados explícitos** en `CheckoutPage`: union `loading | ready | submitting | result | session_invalid | network_error`. Email validado con regex `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`.
- **Componentes**: `SessionSummary`, `CustomerForm`, `StatusView`. `formatAmount` respeta ZERO_DECIMAL set (PYG/JPY/KRW/CLP/VND).
- **Rutas**: `/c/:publicToken`, `/c/:publicToken/return`, `/` → 404, `*` → NotFound.
- **API client**: dev usa proxy Vite `/v1` → `VITE_API_TARGET` (default `http://localhost:3010`). Prod: baseURL desde env.
- **Testing SPA**:
  - Vitest + @testing-library/react + jsdom: `test/utils/format.test.ts` (3) + `test/pages/CheckoutPage.test.tsx` (10). **13 unit tests pasando**.
  - Playwright (Chromium) cross-stack contra backend real: `e2e/helpers/seed.ts` usa `PAY_ADMIN_TOKEN` y crea merchant + secret api-key + intent + session vía admin API. `e2e/checkout.spec.ts`: 5 tests (load+render, missing email, invalid email format, token inválido → "Sesión no disponible", no leak de internal IDs ni `cs_secret_`). `playwright.config.ts` arranca ambos servers (`pnpm start` en `../novasis-pay` + `pnpm dev` local) con `reuseExistingServer: true`.

**Total tests del stack Novasis Pay**: 21 unit + 33 e2e backend + 13 unit + 5 Playwright SPA = **72 tests pasando**.

### 🟡 En curso / pendiente cercano

- **Smoke test real dLocal Go**: el `DlocalAdapter` está implementado (axios + X-Login/X-Trans-Key + HMAC + status/method maps) y unit-tested (`parseWebhook`), pero **nunca se ejecutó contra el sandbox real**. Pendiente: configurar credenciales sandbox, correr `confirm` end-to-end, verificar que `notification_url` se construya correcto y que el webhook entrante valide la firma real.
- **SmartFields / tokenización dLocal**: el SPA actualmente envía sólo customer data (email/name/doc/phone/country) — no carga el SDK JS de dLocal SmartFields para tokenizar tarjeta en navegador. Evaluar si el primer flujo será redirect (dLocal hosted) o in-page con SmartFields.
- **CI del SPA**: workflow propio para `novasis-pay-checkout` (lint + vitest + playwright contra backend en servicios docker). Hoy solo el backend tiene CI mejorado.
- Reset BullMQ entre tests (limpiar Redis o usar database aislada por test run).

### ✅ Admin SPA del gateway (`/var/www/html/proyectos/novasis-pay-admin`)

Repo separado, Vite + React 19 + TypeScript + MUI v7 + react-router-dom v7. Puerto dev `5175`.

- **Auth real con JWT**: backend `AdminAuthModule` (login email+password, `bcrypt` rounds=12, JWT access 15min + refresh 30 días). `AdminUserGuard` reemplaza al `AdminGuard` legacy en `merchant.admin.controller`, `api-key.admin.controller`, `admin-events.controller`. Variable `ADMIN_JWT_SECRET` (min 32 chars). CLI `pnpm seed:admin --email --name --password`.
- **Sesión persistente**: `TokenContext` guarda `{accessToken, refreshToken, user}` en `sessionStorage`, sync cross-tab vía evento `storage`. `apiFetch` con auto-refresh al 401 (single inflight promise).
- **UI 100% español, sin jerga técnica**: pasarelas, comercios, "claves" (no sk_/pk_), ambientes "Pruebas / Producción" (no test/live). Backend conserva enums en inglés como estándar regional.
- **Páginas**: `LoginPage`, `DashboardPage` (métricas: comercios totales, pasarelas activas, alta rápida), `MerchantsListPage` (tabla en md+, cards en xs/sm), `MerchantNewPage` (país con flag dropdown), `MerchantDetailPage` (tabs Datos / Claves API / Notificaciones), `ProvidersPage`.
- **Generación de pares de claves**: nueva ruta `POST /admin/merchants/:id/api-keys/pair` que emite privada+pública del mismo ambiente en una sola operación. La UI muestra checklist visual ("✓ Clave privada · ✓ Clave pública") por ambiente y bloquea generar uno solo. `GET /admin/merchants/:id/api-keys` lista metadata (prefix, etiqueta, último uso, estado). `DELETE` revoca. El secret completo solo viaja en la respuesta del POST.
- **Responsive**: layout adaptado a 430px (cards en mobile, tabla en desktop, logo más grande). Botones full-width en xs.

### ✅ Módulo Novasis Pay en el ERP (`pos-ventas` + `smartfactvoice-backend`)

El ERP es **el primer merchant productivo** del gateway. Conectado a `http://localhost:3010/v1` en local.

- **Backend ERP** (`smartfactvoice-backend/src/novasis-pay/`):
  - `NovasisPayConfigService.upsertConfig()` guarda `merchant_id`, `publishable_key`, `secret_key` cifrada con `NOVASIS_PAY_ENCRYPTION_KEY` (AES-256-CBC, **32 chars exactos** — distinta de `ADMIN_API_TOKEN` del gateway).
  - `NovasisPayClient.testConnection(empresaId)` descifra la clave privada y golpea `GET {api_base_url}/merchants/me` con `Authorization: Bearer sk_…`.
  - DTO `UpsertNovasisPayConfigDto` con `@Matches(/^pk_(test|live)_…)` y `@Matches(/^sk_(test|live)_…)` para validar formato + `@Transform(value.trim())` en `api_base_url` / `checkout_base_url` (evita falla por `\n` invisible).
  - `webhook_secret` validado como string libre 8–256 chars (configurable por soporte).
- **Frontend ERP** (`pos-ventas/src/components/organismos/NovasisPay/CuentasConfig.jsx` + `pages/NovasisPay.jsx`):
  - Pestañas Cuentas / Cobros / Conciliaciones. Las dos últimas son `EmptyState` hasta Fase 2.
  - Form de conexión con `Identificador del comercio` (mer_), `Ambiente` (Pruebas/Producción), `Clave pública`, `Clave privada` (input type="password" con placeholder masked si ya está guardada), `País principal` (dropdown con flag emoji), `Pasarela preferida`, switch `Activar Novasis Pay`.
  - **Validación en vivo del prefijo**: si el usuario pega `sk_…` en el campo público (o viceversa), el input pasa a estado error y muestra `"Esta clave no empieza con pk_test_ — revisá si no pegaste la privada por error"`. El prefijo esperado se calcula desde `form.mode` (live → `pk_live_`, test → `pk_test_`).
  - "Configuración avanzada" (Accordion colapsado): `webhook_secret`, `api_base_url` (default `https://api.pay.novasis.com/v1`, en local `http://localhost:3010/v1`), `checkout_base_url` (default `https://pay.novasis.com/checkout`).
  - Diálogo "¿Cómo las consigo?" explica que las claves las emite Novasis Pay (`soporte@novasis.pay`), no se crean en el ERP.
  - Botones `Probar conexión` (deshabilitado si no hay secret guardado) y `Guardar`. Tras guardar con éxito limpia el campo `secret_key` (el backend nunca lo devuelve).
  - Chips de estado: `Conectado` / `Sin conectar`, `Pruebas` / `Producción`.

### 🟦 Hitos validados manualmente en esta sesión

- Admin SPA en `localhost:5175` autentica con JWT, crea comercio "Acme S.A." (`mer_pa4c2rh8g4mqyd8vq28qqjct`), genera par de claves `pk_test_…` + `sk_test_…` en Pruebas.
- ERP en `localhost:5174` guarda esas claves cifradas + `api_base_url=http://localhost:3010/v1`.
- `Probar conexión` → "Conexión OK — Acme S.A." (el gateway resuelve `GET /merchants/me` con la `sk_test_` del comercio).
- Toda la pila funciona sobre `localhost` con `NOVASIS_PAY_ENCRYPTION_KEY` generada vía `openssl rand -hex 16`.

### ⏳ Pendiente más adelante

- Más providers: **Bancard**, **Pagopar**, **Dinelco** (cada uno con adapter + parseWebhook + status map).
- SDK TS publicable (`@novasis/pay-node`, `@novasis/pay-js`) generado desde `openapi.json`.
- Dashboard externo del merchant (Fase 9): self-service de API keys, provider configs, webhooks, lectura de intents/refunds/events.
- Billing del propio gateway: `MerchantPricingPlan` + `MerchantInvoice` (modelos ya en schema, falta lógica de facturación mensual).
- `AuditLog` automático sobre acciones admin + cambios sensibles (rotación de keys, cambios de credentials, retry manual).
- Integración del ERP smartfactvoice como **merchant** del gateway (reemplaza módulo Bancard interno).

---

## Estrategia de testing

### Stack

- **Vitest** + **SWC** (decorator metadata para NestJS).
- **Supertest** para HTTP black-box sobre la app real.
- **DB real** (Postgres del docker-compose, schema `test` separado vía `DATABASE_URL=...?schema=test`).
- **Redis real** del docker-compose para BullMQ.
- Sin mocks de Prisma. Mocks solo en el borde externo (provider HTTP).

### Estructura

```
novasis-pay/
  .env.test                              # vars específicas de test
  vitest.config.ts                       # unit specs en src/**/*.spec.ts
  vitest.e2e.config.ts                   # e2e en test/**/*.e2e-spec.ts (singleFork)
  src/**/*.spec.ts                       # unit specs co-ubicados
  test/
    helpers/
      test-app.ts                        # build AppModule con overrides
      db.ts                              # truncateAll() entre tests
      seed.ts                            # seedMerchantWithApiKey()
      fake-provider-router.ts            # override determinístico de ProviderRouterService
    payment-intent.e2e-spec.ts           # ✅
    refund.e2e-spec.ts                   # ✅
    webhook-outbound.e2e-spec.ts         # ✅
    webhook-inbound.e2e-spec.ts          # ✅
    public-checkout.e2e-spec.ts          # ✅ (hosted checkout backend)
```

### Niveles

1. **Unit (vitest)** — lógica pura, sin DI: status maps de adapters, firma/verificación HMAC, encryption round-trip, id generators, cálculos Decimal de refund remaining.
2. **E2E (vitest + supertest)** — flows completos sobre la app booteada (`Test.createTestingModule(AppModule)`). DB real, Redis real, **provider mockeado** vía `FakeProviderRouterService` que se programa por test (`createStatus: "approved" | "pending" | ...`).

### Convenciones

- Cada `*.e2e-spec.ts` arranca un único `INestApplication` con `beforeAll`, lo cierra en `afterAll`.
- `beforeEach` corre `truncateAll(prisma)` + reseed mínimo. Nada de orden implícito entre tests.
- Idempotency-Keys, externalReferences y emails se generan con `Date.now()` para evitar colisiones entre runs si la truncación falla.
- Adapter externo (dLocal HTTP) **nunca** se llama en e2e — el override de `ProviderRouterService` devuelve un `FakeAdapter` determinístico.
- Para tests del webhook entrante real (firma HMAC de dLocal), se usa `createHmac("sha256", secret)` para forjar la firma esperada y se postea con `supertest`.

### Scripts

```
pnpm test                    # unit (vitest run)
pnpm test:watch              # unit en watch
pnpm test:cov                # unit con coverage
pnpm test:e2e:setup          # prisma migrate deploy contra DATABASE_URL de .env.test
pnpm test:e2e                # e2e (vitest run --config ./vitest.e2e.config.ts)
```

### Orden recomendado para correr e2e localmente

```
docker compose up -d postgres redis
pnpm test:e2e:setup          # solo la primera vez o tras nuevas migraciones
pnpm test:e2e
```

### Cobertura mínima objetivo Fase 1

- ✅ Payment intent: auth, create, idempotency, confirm (approved/pending), 401/404.
- ✅ Refunds: full/parcial, cálculo remaining, intent → refunded/partially_refunded.
- ✅ Webhook outbound: emit crea rows en `webhook_event_out`, processor firma + retry exponencial.
- ✅ Webhook inbound: firma válida → estado actualizado; firma inválida → 401 + persist con error.
- ✅ Scopes: `restricted` no puede tocar provider-configs ni webhook-endpoints.
- ✅ Admin: listOutbound/retry sólo con `adm_` token; otros 401.
- ✅ Public checkout: load + 404 uniformes + confirm + lockout + headers helmet.
- ✅ SPA: render + validación email + manejo de token inválido + no leak de IDs.

### Frontend SPA — testing (`novasis-pay-checkout`)

```
novasis-pay-checkout/
  vitest.config.ts            # jsdom, exclude e2e/
  playwright.config.ts        # webServers: backend + frontend, reuseExistingServer
  test/
    setup.ts
    helpers/render.tsx
    utils/format.test.ts                 # 3 tests ✅
    pages/CheckoutPage.test.tsx          # 10 tests ✅
  e2e/
    helpers/seed.ts                      # admin API → merchant + key + intent + session
    checkout.spec.ts                     # 5 Playwright tests ✅
```

`pnpm test:e2e` requiere `PAY_ADMIN_TOKEN` en el env (copiarlo de `novasis-pay/.env` → `ADMIN_API_TOKEN`).

### CI

**Backend** (`novasis-pay/.github/workflows/ci.yml`) — ✅ implementado:
```yaml
services:
  postgres: { image: postgres:16-alpine, ... }
  redis:    { image: redis:7-alpine, ... }
steps:
  - pnpm install
  - pnpm prisma:generate
  - pnpm prisma migrate deploy
  - pnpm test                # unit (21)
  - pnpm test:e2e            # e2e (33)
  - pnpm openapi:check       # snapshot freshness
  - pnpm openapi:lint        # @redocly/cli
```

**SPA** (`novasis-pay-checkout`) — 🟡 pendiente: workflow propio que corra `pnpm test` (vitest) y `pnpm test:e2e` (Playwright) con backend levantado en servicios docker.

---

## Fase 2 — Cobros desde el ERP

Estado: **flujo end-to-end funcional contra dLocal Go real**. Una factura del ERP genera intent + checkout session, el cliente paga en el hosted checkout, y el estado se sincroniza al ERP (hoy vía pull manual; webhook real pendiente de túnel).

### ✅ Implementado

**Backend ERP** (`smartfactvoice-backend/src/novasis-pay/`):

- Modelo `novasis_pay_cobro` (Prisma): `id`, `empresa_id`, `factura_id?`, `intent_id`, `session_id`, `checkout_url`, `monto`, `moneda`, `status` (enum `novasis_pay_cobro_status`: `pendiente|aprobado|rechazado|expirado|cancelado`), `expires_at`, `paid_at`, `metadata`, timestamps. Migración aplicada `20260609_novasis_pay_cobro_expires_at`.
- Modelo `novasis_pay_customer_ref` (mapping ERP cliente ↔ customerId del gateway) con UNIQUE parciales por `(empresa_id, email)` y `(empresa_id, doc_type+doc_number)`.
- `NovasisPayCobrosService.iniciarCobro()`:
  - Garantiza customer en gateway: `ensureGatewayCustomer()` busca mapping local, si no existe `POST /customers`; **recuperación 409**: si el gateway responde 409 (customer ya existe por UNIQUE email/doc), hace `findCustomerByEmailOrDoc()` y persiste el mapping local. Race condition P2002 manejado.
  - `POST /payment-intents` con `Idempotency-Key = cobro_id`, `externalReference = factura.numero`, `customerId` (no inline customer data — el gateway no lo acepta).
  - `POST /checkout-sessions` con `returnUrl=/pago-exitoso`, `cancelUrl=/pago-cancelado` apuntando al ERP. Persiste `expires_at` desde `session.expiresAt`.
  - Validación de mínimo por moneda en backend (rebote del gateway dLocal `5016 Amount too low` traducido a `BadGatewayException` con detalle).
- `NovasisPayCobrosService.sync(empresaId, id)` — **pull chain ERP → gateway → dLocal**:
  - Llama `POST /payment-intents/:id/sync` en el gateway.
  - Gateway resuelve adapter desde `(merchantId, country, currency, requestedProvider)` y hace `adapter.getPayment(providerPaymentId)` contra dLocal.
  - Actualiza attempt + intent en gateway, emite webhook outbound, devuelve intent fresco.
  - ERP mapea status (`mapIntentStatus`) y actualiza `novasis_pay_cobro`. Fallback a `getPaymentIntent` si el sync falla.
- Endpoints en `NovasisPayController`:
  - `POST /novasis-pay/cobros` (iniciar cobro libre o ligado a factura).
  - `GET /novasis-pay/cobros` (listado paginado con filtros) + `GET /:id`.
  - `POST /novasis-pay/cobros/:id/sync` (refresh manual contra dLocal).
  - Receiver `POST /webhooks/novasis-pay` con verificación HMAC (implementado, pero hoy no se ejercita en dev por falta de túnel).
- Cliente HTTP (`NovasisPayClient`): añadidos `findCustomerByEmailOrDoc`, `getPaymentIntent`, `syncPaymentIntent`.
- Tests: 9/9 pasando en `novasis-pay-cobros.service.spec.ts` (incluye caso 409 → lookup → persist).

**Gateway** (`novasis-pay/src/payment-intents/`):

- `PaymentIntentService.syncFromProvider()` + endpoint `POST /payment-intents/:id/sync` (pull-based desde el provider, actualiza attempt + intent + emite webhook outbound).
- `DlocalAdapter.createPayment` rethrow como `BadGatewayException` con `detail` (antes propagaba el error axios crudo).
- `CustomerService.list()` admite filtros `email` / `docNumber` para recuperar mapping local tras 409.

**Frontend ERP** (`pos-ventas/src/components/organismos/NovasisPay/Cobros.jsx` + `pages/PagoResultadoNovasis.jsx`):

- Pestaña "Cobros" reemplaza `EmptyState`:
  - Cards (`CobroItem`) con avatar de cliente, monto formateado, moneda, status con icon + color, fecha de creación y **fecha/tiempo de vencimiento** (`calcularVencimiento` muestra "vence en 14m" / "vencido hace 2h" con tono semántico).
  - Botón refresh por cobro → `useSincronizarNovasisPayCobroMutation`. Toast con resultado por status.
  - `refetchInterval: 15_000` mientras hay cobros pendientes.
- `NuevoCobroDialog` rediseñado:
  - Secciones (`SeccionForm`): Cliente / Pago / Avanzado (Collapse).
  - `MonedaInput` para monto, validación mínimo por moneda (`MONTO_MINIMO.PYG = 10000`, USD/ARS/BRL/CLP/UYU también mapeados). Botón submit deshabilitado si inválido.
  - `FieldHint` con ejemplos, `Alert` inline si falla la creación.
- Páginas públicas `/pago-exitoso` y `/pago-cancelado`: pill `PoweredByNovasis` (branding gradient + "Procesado de forma segura por Novasis Pay").
- `AppShell` (`App.jsx`): `RUTAS_PUBLICAS_EXTERNAS` oculta `AyudaIaFab`, `SuscripcionBloqueadaAlert`, `CommandPalette` en rutas públicas (`useLocation().pathname`).

**Servicio API** (`pos-ventas/src/api/novasis-pay.service.js`) + tanstack (`NovasisPayStack.jsx`):

- `sincronizarNovasisPayCobro(id)` + `useSincronizarNovasisPayCobroMutation` con feedback por status.

### 🟡 Pendiente

- **Webhook real en dev**: levantar túnel (ngrok/cloudflared) para que dLocal → gateway → ERP entreguen webhooks sin pull manual. Hoy el pull-based sync cubre el caso, pero requiere acción del usuario.
- **Botón "Cobrar con Novasis Pay" en detalle de factura**: el flujo se dispara desde la pestaña "Cobros" del módulo. Falta integrar en el detalle de factura (cuando `saldo > 0` y `cfg.activo`).
- **QR del checkout en el modal post-creación**: hoy se muestra el link, falta `qrcode.react` para checkout asistido en caja física.
- **Aplicación contable automática**: el paso de cobro `aprobado` no genera asiento. Las cuentas (`cuenta_contable_por_liquidar` / `_banco` / `_comision`) están en el form de configuración pero no se consumen aún. Decisión #3 sigue abierta.
- **Lista de Conciliaciones**: pestaña sigue siendo `EmptyState`.
- **Refunds desde la UI** del ERP (el gateway ya soporta refund full/parcial).
- **Smoke test real dLocal Go** documentado: el flujo se probó manualmente en esta sesión (intent `pi_01ktqp2gn11bwezq…` aprobado en dLocal dashboard y sincronizado al ERP), falta capturarlo como e2e reproducible.

### Decisiones resueltas durante la implementación

1. **Botón Cobrar** — primera iteración en pestaña "Cobros" (cobro libre con cliente opcional). Integración en factura queda como siguiente paso.
2. **Webhook en local** — se eligió **pull-based sync manual** (botón refresh por cobro) como alternativa al túnel. El receiver HMAC está listo; el túnel se sumará cuando se requiera tiempo-real.
3. **Aplicación contable** — postergada. Por ahora solo se marca el cobro `aprobado`; la conciliación contable la hace el operador.

---

## Fase 3 — Billing / facturación del fee (en curso)

Implementa el modelo de negocio descrito arriba (Gateway puro). El gateway necesita medir la actividad de cada merchant y facturarle el fee mensual + fee por transacción. Sin esto, no se monetiza la pasarela.

Repo principal: `/var/www/html/proyectos/novasis-pay` (módulo `src/billing/` — esqueleto vacío hoy).
Admin SPA: `/var/www/html/proyectos/novasis-pay-admin`.

### Entregable 1 — Métricas de transacciones ✅ (2026-06-10)

**Backend** (`novasis-pay/src/billing/`):
- `BillingStatsService.merchantStats(merchantId, period?)` — cuenta `payment_intent` con `status = approved` y suma `amount` agrupado por `currency`, dentro del período (default: mes calendario actual). También devuelve breakdown rechazados / pendientes.
- `BillingStatsService.globalStats(period?)` — agregado para el dashboard admin: total tx aprobadas, volumen por moneda, total comercios activos, top 5 comercios por volumen.
- Endpoints admin (protegidos con `AdminUserGuard`):
  - `GET /admin/billing/stats?period=YYYY-MM`
  - `GET /admin/merchants/:id/billing/stats?period=YYYY-MM`
- DTOs documentados en Swagger bajo tag `Admin / Billing`.

**Admin SPA** (`novasis-pay-admin`):
- `DashboardPage`: reemplazar los tiles "Comercios" / "Pasarelas integradas" por una grilla con:
  - **Tx aprobadas (mes)** — número grande + delta vs mes anterior.
  - **Volumen procesado** — por moneda (PYG destacado, USD secundario).
  - **Comercios activos** — count con link a listado.
  - **Pasarelas integradas** (se mantiene).
- `MerchantDetailPage`: nueva tab **"Actividad"** con tx aprobadas/rechazadas/pendientes del mes actual + volumen por moneda. Selector de período (este mes / mes anterior).

### Entregable 2 — Plan de pricing por comercio ✅ (2026-06-10)

**Backend** (`novasis-pay/src/billing/`):
- `PricingPlanService` con `create`, `list`, `getActive`, `expire`. Invariante "un solo plan activo por merchant" garantizada en transacción (al crear, expira los anteriores con `validUntil = now`).
- `PricingPlanAdminController` con:
  - `POST /admin/merchants/:merchantId/pricing-plans`
  - `GET /admin/merchants/:merchantId/pricing-plans`
  - `GET /admin/merchants/:merchantId/pricing-plans/active`
  - `POST /admin/merchants/:merchantId/pricing-plans/:planId/expire`
- DTOs validados con `class-validator`: `monthlyFee`, `monthlyFeeCurrency`, `txFeeFixed`, `txFeePercentage`, `txFeeCurrency`, `includedTxPerMonth`, `overQuotaTxFee` (todos opcionales con defaults razonables).

**Admin SPA**:
- `components/PricingPlanSection.tsx` reutilizable: card del plan vigente + historial + dialog "Nuevo plan".
- `MerchantDetailPage`: tab **"Plan"** integrada con `PricingPlanSection`. Si no hay plan, Alert info "Sin plan, no se factura".

### Entregable 3 — Facturación mensual ✅ (2026-06-10)

**Backend**:
- `InvoiceService.generate({ merchantId?, period? })`: lee plan vigente, agrega tx aprobadas (`groupBy` status + sum amount), arma items por concepto (`monthly_fee`, `tx_fee_fixed`, `tx_fee_percentage`, `over_quota_fee`). UNIQUE `(merchantId, period)` evita duplicados (idempotente: si existe, lo skipea con razón en el resultado).
- Cálculo: tx_fee_fixed solo se aplica a tx que exceden la cuota incluida. tx_fee_percentage se calcula sobre volumen aprobado. over_quota se cobra adicional si hay `includedTxPerMonth` + `overQuotaTxFee`.
- `dueAt = issuedAt + 10 días`.
- `InvoiceAdminController`:
  - `GET /admin/invoices?status=&period=&merchantId=&limit=&cursor=`
  - `GET /admin/invoices/:id`
  - `POST /admin/invoices/generate` (manual, ideal para reproceso y testing)
  - `PATCH /admin/invoices/:id/mark-paid`
- Devuelve `{ generated: Invoice[], skipped: { merchantId, reason }[] }` para que el admin vea qué comercios no facturaron y por qué.

**Admin SPA**:
- Nueva ruta `/invoices` con `InvoicesPage` (listado con `merchantName` + dialog "Generar facturas" con selector de período).
- `components/InvoicesSection.tsx` reutilizable: cards de facturas con status chip, monto total formateado, breakdown mensual/tx, botón "Marcar pagada" si está pending.
- `AdminLayout`: nueva tab "Facturas" en el nav principal.
- `MerchantDetailPage`: tab **"Facturas"** con histórico del comercio (reutiliza `InvoicesSection` con `merchantId` fijo).

**Jobs automáticos (BullMQ, 2026-06-10)**:
- `BillingScheduler` registra en `onApplicationBootstrap` dos repeat jobs (jobId estable para dedup entre restarts), guardable con `BILLING_CRON_DISABLED=1` para tests/dev.
- `generate-monthly-invoices`: cron `0 3 1 * *` UTC (día 1 de cada mes 03:00 UTC) → `InvoiceService.generate()` sin filtros.
- `mark-overdue-invoices`: cron `0 4 * * *` UTC (diario 04:00) → `updateMany` que pasa `pending → overdue` cuando `dueAt < now`.
- `BillingProcessor` con `concurrency: 1` despacha por `job.name`.

**Billing cycle + free + self-billing (2026-06-10)**:
- Nuevo enum `BillingCycle { monthly, quarterly, yearly }` + 3 columnas en `merchant_pricing_plan`:
  - `billing_cycle BillingCycle @default(monthly)`: frecuencia de facturación por comercio. Monthly factura siempre; quarterly solo en cierre de trimestre (mar/jun/sep/dic) cubriendo los 3 meses anteriores; yearly solo en diciembre cubriendo el año calendario. El `monthlyFee` se multiplica por la cantidad de meses del ciclo (×1, ×3, ×12) y las tx aprobadas se agregan sobre la ventana completa.
  - `free Boolean @default(false)`: comercios bonificados/cortesía — `generate()` los skipea con razón "plan free".
  - `self_billing_enabled Boolean @default(false)`: habilita cobro automatizado vía Novasis Pay.
- **Self-billing**: si el plan tiene `selfBillingEnabled=true`, total > 0 y `SELF_BILLING_MERCHANT_ID` está configurado (merchant interno de Novasis con sus propias credenciales de provider), `InvoiceService` crea un `PaymentIntent` en ese merchant con `metadata.kind="self_billing"` + `metadata.invoiceId`, lo persiste en `MerchantInvoice.selfBillingIntentId` y lo devuelve en el DTO. Falla silenciosa con log si el merchant interno no existe — la factura se genera igual y queda cobrable manualmente.
- **Auto mark-paid**: `InvoiceService.onIntentApproved(intentId, metadata)` se llama desde `PaymentIntentService.emitForStatus` y desde `WebhookInboundService` cuando un intent transiciona a `approved`. Si la metadata es self-billing, marca la factura asociada como `paid` + setea `paidAt`. Idempotente (no-op si ya estaba paid o void).
- **Admin SPA**: `PricingPlanSection` agrega selector "Ciclo de cobro" (mensual/trimestral/anual) y switches "Plan free" + "Self-billing". El card del plan vigente muestra chips `FREE`/`Self-billing` y el ciclo. `InvoicesSection` muestra un chip "Self-billing" + el `selfBillingIntentId` en cada factura que tenga uno asociado.

**Tests (2026-06-10)**:
- `invoice.service.spec.ts`: 12 tests unit con PrismaService mockeado — los 6 base + 3 nuevos (skip free, yearly fuera/dentro de diciembre con multiplier ×12, quarterly solo en cierre de trimestre con multiplier ×3) + 3 de `onIntentApproved` (ignora metadata no-self-billing, marca paid, idempotente si ya paid).
- `billing.e2e-spec.ts`: 12 tests e2e — los 9 base + skip free + yearly con multiplier ×12 + self-billing end-to-end (crea intent en merchant interno, valida `externalReference`/`metadata`, simula `onIntentApproved` y verifica que la factura quede paid con `selfBillingIntentId` persistido).
- Suite total: **49/49 e2e, 33/33 unit**.

**Pendiente Entregable 3**: ninguno — entregable cerrado.

### Entregable 4 — Admin de pasarelas por comercio ✅ (2026-06-10)

Cierra el gap entre el catálogo global (`/providers`) y la asociación per-merchant de credenciales: el modelo Gateway puro requiere que el comercio cree sus propias cuentas en dLocal/Bancard/etc, y el panel admin necesitaba un lugar para registrarlas.

**Backend** (`novasis-pay/src/provider-configs/`):
- `ProviderConfigService.remove(merchantId, id)` — nuevo método con validación de pertenencia.
- `ProviderConfigAdminController` (montado en `/admin/merchants/:merchantId/provider-configs`, `AdminUserGuard`):
  - `POST` crear pasarela (provider + mode + country + credenciales JSON; encripta con AES-256-GCM).
  - `GET` listar pasarelas del comercio.
  - `GET :id` obtener una.
  - `PATCH :id` actualizar credenciales (re-encripta), prioridad, fxStrategy, active.
  - `DELETE :id` eliminar.
- Las credenciales nunca se devuelven por API; solo `hasCredentials: boolean` en el DTO.
- Constraint UNIQUE `(merchantId, provider, country, mode)` → 409 si se duplica.

**Admin SPA** (`novasis-pay-admin`):
- `api/providerConfigs.ts` — cliente tipado para los 5 endpoints.
- `components/ProviderConfigsSection.tsx`: tabla con pasarelas registradas (provider, país, ambiente, fx, prioridad, estado de credenciales, switch active), botones agregar/editar/eliminar y dialog con hints de credenciales por provider (dlocal: `apiKey`+`secretKey`; bancard: `publicKey`+`privateKey`; etc.). Edit no obliga a re-tipear credenciales (las mantiene si quedan vacías).
- `MerchantDetailPage`: nueva tab **"Pasarelas"** entre Facturas y Claves API.

**Tests (2026-06-10)**:
- `provider-configs-admin.e2e-spec.ts`: 6 tests e2e — auth requerido, create + list + retrieve sin leakear credenciales (verifica encrypted en DB), 409 por duplicado, PATCH re-encripta + toggle active, DELETE, 404 en id desconocido.
- Suite total acumulada: **55/55 e2e, 33/33 unit**.

### Cosas que explícitamente NO se hacen en Fase 3

- KYC propio del comercio (lo gestiona el provider).
- Payouts / liquidación de fondos a comercios (no aplica al modelo Gateway puro).
- Conciliación bancaria multi-comercio (no tocamos el dinero del comercio).
- Chargebacks (van directo contra la cuenta del comercio en el provider).

---

## Referencias

- dLocal Go API: https://docs.dlocalgo.com/integration-api
- Plan previo (descartado, parcialmente reutilizable): `plan-bancard-vpos-compra-asistida.md`
- Backend ERP: `/var/www/html/proyectos/smartfactvoice-backend`
- Frontend ERP: `/var/www/html/proyectos/pos-ventas`
- Repo gateway: `/var/www/html/proyectos/novasis-pay`
- Repo hosted checkout SPA: `/var/www/html/proyectos/novasis-pay-checkout`
- Repo admin SPA del gateway: `/var/www/html/proyectos/novasis-pay-admin` (puerto dev `5175`)
