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
8 changes: 8 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

458 changes: 458 additions & 0 deletions .idea/dbnavigator.xml

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions .idea/yape-challenge.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 85 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,48 +1,45 @@
# Yape Code Challenge :rocket:
# Yape Code Challenge – Servicios de Transacciones y Anti-Fraude

Our code challenge will let you marvel us with your Jedi coding skills :smile:.
## Descripción general

Don't forget that the proper way to submit your work is to fork the repo and create a PR :wink: ... have fun !!
Este repositorio implementa un sistema orientado a eventos que modela el ciclo de vida de una transacción financiera, la cual debe ser validada por un servicio de anti-fraude antes de confirmar su estado final.

- [Problem](#problem)
- [Tech Stack](#tech_stack)
- [Send us your challenge](#send_us_your_challenge)
La solución está compuesta por dos servicios independientes que se comunican de manera asíncrona mediante Kafka:

# Problem
- **Transaction Service**: expone APIs HTTP para crear y consultar transacciones, gestiona la persistencia y publica eventos.
- **Anti-Fraud Service**: consume eventos de transacciones creadas, aplica reglas de validación y publica el resultado.

Every time a financial transaction is created it must be validated by our anti-fraud microservice and then the same service sends a message back to update the transaction status.
For now, we have only three transaction statuses:
El sistema sigue un modelo de **consistencia eventual** y está diseñado considerando confiabilidad, idempotencia y escenarios de alto volumen.

<ol>
<li>pending</li>
<li>approved</li>
<li>rejected</li>
</ol>
### Principios aplicados

Every transaction with a value greater than 1000 should be rejected.
- Comunicación asíncrona mediante eventos
- Arquitectura orientada a eventos
- Consistencia eventual
- Consumers idempotentes
- Publicación confiable de eventos usando Outbox Pattern

```mermaid
flowchart LR
Transaction -- Save Transaction with pending Status --> transactionDatabase[(Database)]
Transaction --Send transaction Created event--> Anti-Fraud
Anti-Fraud -- Send transaction Status Approved event--> Transaction
Anti-Fraud -- Send transaction Status Rejected event--> Transaction
Transaction -- Update transaction Status event--> transactionDatabase[(Database)]
```
---

## Servicios

### 1. Transaction Service

#### Responsabilidades

# Tech Stack
- Crear transacciones con estado inicial `pending`
- Persistir información de la transacción
- Publicar eventos `TransactionCreated`
- Consumir eventos `TransactionValidated` para actualizar el estado
- Exponer APIs HTTP para consulta

<ol>
<li>Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma) </li>
<li>Any database</li>
<li>Kafka</li>
</ol>
---

We do provide a `Dockerfile` to help you get started with a dev environment.
### Endpoints

You must have two resources:
#### Crear transacción

1. Resource to create a transaction that must containt:
**POST** `/transactions`

```json
{
Expand All @@ -53,30 +50,76 @@ You must have two resources:
}
```

2. Resource to retrieve a transaction
Respuesta:

```json
{
"transactionExternalId": "Guid",
"transactionStatus": {
"name": "pending"
}
}
```

---

#### Obtener transacción

**GET** `/transactions/{transactionExternalId}`

```json
{
"transactionExternalId": "Guid",
"transactionType": {
"name": ""
"name": "TRANSFER"
},
"transactionStatus": {
"name": ""
"name": "approved | rejected | pending"
},
"value": 120,
"createdAt": "Date"
"createdAt": "ISO Date"
}
```

## Optional
---

You can use any approach to store transaction data but you should consider that we may deal with high volume scenarios where we have a huge amount of writes and reads for the same data at the same time. How would you tackle this requirement?
## Ejecución local

You can use Graphql;
### Requisitos

# Send us your challenge
- Node.js 18+
- pnpm
- Docker / Docker Compose

---

### Pasos

#### 1. Levantar infraestructura (Kafka + PostgreSQL)

```bash
pnpm dev:infra
```

#### 2. Instalar dependencias

```bash
pnpm install
```

#### 3. Aplicar esquema de base de datos

```bash
pnpm db:push
```

#### 4. Ejecutar servicios

```bash
pnpm dev:transaction
pnpm dev:antifraud
```

When you finish your challenge, after forking a repository, you **must** open a pull request to our repository. There are no limitations to the implementation, you can follow the programming paradigm, modularization, and style that you feel is the most appropriate solution.
---

If you have any questions, please let us know.
## Adrian Lopez
1 change: 1 addition & 0 deletions apps/anti-fraud-service/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
KAFKA_BROKERS=localhost:9092
19 changes: 19 additions & 0 deletions apps/anti-fraud-service/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@yape/anti-fraud-service",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js"
},
"dependencies": {
"@yape/contracts": "workspace:*",
"@yape/kafka": "workspace:*",
"pino": "^9.3.2"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}
3 changes: 3 additions & 0 deletions apps/anti-fraud-service/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const config = {
kafkaBrokers: (process.env.KAFKA_BROKERS || "localhost:9092").split(",")
};
4 changes: 4 additions & 0 deletions apps/anti-fraud-service/src/domain/antiFraudPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function validate(value: number): { status: "approved" | "rejected"; reason: string } {
if (value > 1000) return { status: "rejected", reason: "Value greater than 1000" };
return { status: "approved", reason: "OK" };
}
21 changes: 21 additions & 0 deletions apps/anti-fraud-service/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { logger } from "./utils/logger";
import { startCreatedConsumer } from "./messaging/consume-created";

async function main() {
const consumer = await startCreatedConsumer();
logger.info("Anti-fraud service started");

const shutdown = async () => {
logger.info("Shutting down...");
await consumer.stop();
process.exit(0);
};

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}

main().catch((e) => {
logger.error({ err: e }, "Fatal error");
process.exit(1);
});
76 changes: 76 additions & 0 deletions apps/anti-fraud-service/src/messaging/consume-created.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createKafka, createConsumer, createProducer, safePublish } from "@yape/kafka/src";
import { config } from "../config";
import { TOPICS } from "./topics";
import { logger } from "../utils/logger";
import { TransactionCreatedEventSchema, TransactionValidatedEventSchema } from "@yape/contracts/src";
import { validate } from "../domain/antiFraudPolicy";
import { randomUUID } from "crypto";

const processed = new Set<string>(); // Ya en producción debería de ser BBDD/Redis

export async function startCreatedConsumer() {
const kafka = createKafka({ clientId: "anti-fraud-service", brokers: config.kafkaBrokers });
const consumer = await createConsumer(kafka, "anti-fraud-service");
const producer = await createProducer(kafka);

await consumer.subscribe({ topic: TOPICS.created, fromBeginning: true });

await consumer.run({
autoCommit: false,
eachMessage: async ({ topic, partition, message }) => {
const raw = message.value?.toString() || "";

try {
const created = TransactionCreatedEventSchema.parse(JSON.parse(raw));

// Aplicamos idempotencia
if (processed.has(created.eventId)) {
await consumer.commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }]);
return;
}

const res = validate(created.value);

const now = new Date();
const validated = TransactionValidatedEventSchema.parse({
eventId: randomUUID(),
correlationId: created.correlationId,
occurredAt: now.toISOString(),
transactionExternalId: created.transactionExternalId,
status: res.status,
reason: res.reason,
validatedAt: now.toISOString()
});

await safePublish(producer, TOPICS.validated, created.transactionExternalId, validated, {
"x-correlation-id": created.correlationId
});

processed.add(created.eventId);

logger.info(
{ transactionExternalId: created.transactionExternalId, status: validated.status },
"Validated transaction"
);

await consumer.commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }]);
} catch (e: any) {
logger.error({ err: e, raw }, "Created consumer error -> DLQ");

// DLQ por si no procesa el mensaje
try {
await safePublish(producer, TOPICS.createdDlq, message.key?.toString() || "dlq", { raw, error: e?.message });
} catch {}

await consumer.commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }]);
}
}
});

const stop = async () => {
await consumer.disconnect();
await producer.disconnect();
};

return { stop };
}
5 changes: 5 additions & 0 deletions apps/anti-fraud-service/src/messaging/topics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const TOPICS = {
created: "transactions.created",
validated: "transactions.validated",
createdDlq: "transactions.created.dlq"
} as const;
2 changes: 2 additions & 0 deletions apps/anti-fraud-service/src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import pino from "pino";
export const logger = pino({ level: process.env.LOG_LEVEL || "info" });
10 changes: 10 additions & 0 deletions apps/anti-fraud-service/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
3 changes: 3 additions & 0 deletions apps/transaction-service/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
PORT=3000
DATABASE_URL=postgresql://app:app@localhost:5432/yape?schema=public
KAFKA_BROKERS=localhost:9092
33 changes: 33 additions & 0 deletions apps/transaction-service/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@yape/transaction-service",
"version": "1.0.0",
"private": true,
"prisma": {
"schema": "prisma/schema.prisma"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",

"prisma:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:reset": "prisma migrate reset",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.19.1",
"@yape/contracts": "workspace:*",
"@yape/kafka": "workspace:*",
"fastify": "^4.28.1",
"pino": "^9.3.2"
},
"devDependencies": {
"prisma": "^5.22.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

Loading