Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.docker
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
NODE_ENV=development
PORT=3000

DATABASE_URL=postgresql://yape:yape_secret@postgres:5432/yape_transfers
REDIS_URL=redis://redis:6379
KAFKA_BROKERS=redpanda:9092
KAFKA_CLIENT_ID=yape-transfer-service
KAFKA_GROUP_ID_ORCHESTRATOR=transfer-orchestrator
KAFKA_GROUP_ID_WALLET=wallet-service
KAFKA_GROUP_ID_FX=fx-service
KAFKA_GROUP_ID_RECEIPT=receipt-service
KAFKA_GROUP_ID_READ_MODEL=read-model-projector

FX_PROVIDER_FAILURE_RATE=0
FX_TIMEOUT_MS=5000
FX_AMBIGUOUS_MAX_RETRIES=3
FX_AMBIGUOUS_RETRY_INTERVAL_MS=5000
SAGA_OUTBOX_POLL_INTERVAL_MS=200
147 changes: 147 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
### Java ###
*.class
*.jar
*.war
*.ear
*.log
*.ctxt
.mtj.tmp/
hs_err_pid*
replay_pid*

### Maven ###
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar

### Gradle ###
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### Spring Boot ###
*.log
spring.log
*.gz

### IntelliJ IDEA ###
.idea/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
*.code-workspace

### macOS ###
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

### Windows ###
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk

### Linux ###
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*

### Environment Variables ###
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

### Application Specific ###
application-local.properties
application-local.yml
application-dev.properties
application-dev.yml
application-secrets.properties
application-secrets.yml

### Logs ###
logs/
*.log
log/

### Temporary Files ###
*.tmp
*.temp
*.swp
*.swo
*~.nib
*.bak
*.orig

### Node (if using frontend tools) ###
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

### Database ###
*.db
*.sqlite
*.sqlite3

### Docker ###
*.dockerignore
docker-compose.override.yml

### Custom ###
148 changes: 148 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Architecture Decision Records — Yape Transfer Service

Cada decisión documenta el contexto, las alternativas descartadas y el trade-off aceptado.
El objetivo es que el ingeniero que lea este código en 6 meses entienda el *por qué*, no solo el *qué*.

---

## ADR-001: Saga con orquestador centralizado (no coreografía)

**Contexto:** Necesitamos coordinar Debit → Credit → FX → Receipt con rollback automático si algún paso falla.

**Decisión:** Orquestador con state machine embebida en `TransferOrchestratorService`.

**Alternativas descartadas:**

| Alternativa | Por qué no |
|---|---|
| Coreografía (eventos reactivos) | El estado de la saga queda distribuido entre servicios. Trazar qué pasó con una transferencia requiere correlacionar eventos de 4 consumidores distintos. La compensación se vuelve implícita y frágil. |
| Temporal.io | Excelente para producción, pero requiere un servidor Temporal separado. Para un sistema de pagos nuevo, agrega una dependencia operacional antes de validar el negocio. |
| Two-Phase Commit (2PC) | No funciona entre servicios con DBs independientes. Bloquea recursos durante la coordinación y no tolera particiones de red. Es exactamente el antipatrón que este sistema evita. |

**Trade-off aceptado:** El orquestador es un single point of coordination. Si se escala horizontalmente, el `processed_events` + idempotencia de estado previenen doble procesamiento. El riesgo de acoplamiento se controla porque el orquestador no conoce la implementación interna de cada servicio — solo emite comandos y escucha eventos.

---

## ADR-002: Transactional Outbox para garantía de entrega

**Contexto:** El problema clásico: ¿cómo garantizar que si cambiamos estado en DB *y* publicamos a Kafka, ambas operaciones son atómicas?

**Decisión:** Escribir eventos a la tabla `outbox_events` en la misma transacción que el cambio de estado. Un `OutboxRelay` independiente publica desde outbox a Kafka con `FOR UPDATE SKIP LOCKED`.

**Por qué no publicar directo a Kafka dentro de la transacción:**
```
BEGIN
UPDATE saga_instances ... ← si esto commiteó
kafkaProducer.publish(...) ← y esto falla por red
COMMIT
→ Estado cambió pero evento nunca llegó a Kafka. La saga queda bloqueada para siempre.
```

**Por qué `FOR UPDATE SKIP LOCKED`:**
Permite correr múltiples instancias del relay en paralelo (escalado horizontal) sin que se bloqueen entre sí. Cada instancia toma un lote diferente de eventos pendientes.

**Garantía resultante:** At-least-once delivery. Los consumidores de Kafka deben ser idempotentes (ver ADR-003).

---

## ADR-003: Idempotencia en dos capas

**Contexto:** Kafka garantiza at-least-once. El OutboxRelay puede republicar un evento si cae entre el publish y el UPDATE a PUBLISHED. Cada consumidor puede recibir el mismo evento más de una vez.

**Decisión:** Doble capa de protección:

**Capa 1 — `processed_events` table:**
```sql
INSERT INTO processed_events (event_id, consumer_group)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
-- rowCount = 0 → ya procesado → ignorar
```
Cada (eventId, consumerGroup) es único. El primer procesamiento lo registra; los siguientes lo detectan y abortan sin efecto.

**Capa 2 — Validación de estado en el orquestador:**
```typescript
if (saga.status !== expectedStatus) return; // ignorar silenciosamente
```
Si el estado de la saga ya avanzó, el evento llegó tarde y se descarta.

**Por qué dos capas y no una:**
`processed_events` cubre duplicados exactos (mismo eventId). La validación de estado cubre el caso donde un evento distinto intenta una transición que ya ocurrió por otro camino (e.g., retry de FX_AMBIGUOUS después de que otro reintento ya resolvió).

---

## ADR-004: Optimistic locking en wallets con RETURNING id

**Contexto:** Dos transferencias pueden debitar la misma wallet simultáneamente. Necesitamos prevenir que el balance quede negativo.

**Decisión:** UPDATE con `WHERE version = $expected AND balance >= $amount RETURNING id`.

```sql
UPDATE wallets
SET balance_cents = balance_cents - $1, version = version + 1
WHERE id = $2 AND version = $3 AND balance_cents >= $1 AND status = 'ACTIVE'
RETURNING id
```

**Por qué no SELECT FOR UPDATE:**
FOR UPDATE bloquea la fila durante toda la transacción. Con alta concurrencia, las transferencias se serializan completamente. El optimistic locking permite concurrencia real: solo hay conflicto si dos transacciones tocan la misma wallet simultáneamente, y la segunda reintenta con la versión actualizada.

**Por qué `RETURNING id` en vez de `rowCount`:**
TypeORM con pg retorna el resultado de RETURNING como array de objetos. `result.rowCount` no es confiable en este contexto. Si el array está vacío → conflicto de versión → retry.

**Por qué `balance >= amount` también en el WHERE:**
`canDebit()` opera sobre datos leídos antes de la transacción. Entre el SELECT y el UPDATE, otro proceso pudo haber debitado la wallet. El WHERE actúa como segunda garantía atómica.

---

## ADR-005: Estado FX_AMBIGUOUS para timeouts indeterminados

**Contexto:** El proveedor FX es una API externa. Un timeout no es lo mismo que un rechazo:
- Rechazo → sabemos que no ejecutó → podemos compensar.
- Timeout → no sabemos si ejecutó → compensar es peligroso (podría revertir un FX ya procesado).

**Decisión:** Estado intermedio `FX_AMBIGUOUS` que suspende la saga sin compensar, esperando resolución del proveedor.

**Flujo:**
```
FX timeout → FX_AMBIGUOUS → reintento cada 5s (hasta 3 veces)
├── FX responde OK → RECEIPT_PENDING → COMPLETED
├── FX confirma fallo → REVERSING → FAILED
└── Sin respuesta tras 3 reintentos → MANUAL_REVIEW
```

**MANUAL_REVIEW como estado consciente:**
No es un estado de error genérico. Significa explícitamente "el sistema no puede resolver esto con certeza — un humano debe investigar". Desde MANUAL_REVIEW, un operador puede transicionar a REVERSING o COMPLETED según lo que encuentre en los logs del proveedor FX.

**Qué se omitió deliberadamente:**
No implementamos un endpoint `PATCH /transfers/:id/resolve` para que operaciones resuelva MANUAL_REVIEW. En producción existiría; aquí el foco es modelar correctamente el estado, no construir el backoffice completo.

---

## ADR-006: CQRS con Redis como read model

**Contexto:** `GET /transfers/:id` puede ser llamado frecuentemente (polling del cliente). Leer directamente de `saga_instances` + joins a `transfers` bajo carga es costoso.

**Decisión:** Proyector separado (consumer group `read-model-projector`) que escucha todos los eventos y mantiene un read model desnormalizado en Redis (TTL 3600s) + PostgreSQL (durable).

**Flujo de lectura:**
```
GET /transfers/:id → Redis (< 5ms) → si miss → PostgreSQL → repopular Redis
```

**Trade-off aceptado:** Consistencia eventual en el read model. Entre que el evento se emite y el projector lo procesa, hay un lag de ~100-500ms. El cliente que hace polling inmediatamente después del POST puede ver PENDING por un momento. Esto es correcto y esperado — el 202 Accepted comunica exactamente esta semántica.

**Por qué no leer directo de la tabla `transfers`:**
El read model agrega información de múltiples eventos (stepHistory, sagaEvents, compensated) que no existe en una sola tabla. Reconstruir eso on-demand con JOINs sería complejo y lento.

---

## Lo que se omitió deliberadamente

| Feature | Por qué no está |
|---|---|
| Dead Letter Queue (DLQ) | En producción, eventos que fallan N veces irían a un tópico DLQ para revisión. Aquí el retry infinito del OutboxRelay es suficiente para demostrar el patrón. |
| Webhook / notificación push | El cliente hace polling. En producción, `TransferCompleted` dispararía un webhook al cliente. Omitido porque está fuera del scope del sistema de pagos en sí. |
| Autenticación / autorización | No hay JWT ni verificación de que `x-user-id` es el dueño del wallet origen. En producción es obligatorio; aquí el foco es la saga y la consistencia. |
| Multi-región / particionamiento por país | Los wallets PE y MX viven en la misma DB. En producción serían DBs separadas por región, y la saga necesitaría manejar latencia cross-region. |
| Backoffice para MANUAL_REVIEW | No hay endpoint para resolver estados ambiguos manualmente. El estado existe y es correcto; la UI de operaciones es trabajo de otro equipo. |
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./

FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "start:dev"]

FROM base AS builder
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/main"]
Loading