diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000000..13566b81b0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/dbnavigator.xml b/.idea/dbnavigator.xml
new file mode 100644
index 0000000000..c87a7ded81
--- /dev/null
+++ b/.idea/dbnavigator.xml
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000000..1655cc71fd
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+ {}
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000000..893dd1c8e0
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000000..6c0b863585
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/yape-challenge.iml b/.idea/yape-challenge.iml
new file mode 100644
index 0000000000..d6ebd48059
--- /dev/null
+++ b/.idea/yape-challenge.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index b067a71026..b4eb654b7d 100644
--- a/README.md
+++ b/README.md
@@ -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.
-
- - pending
- - approved
- - rejected
-
+### 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
-
- - Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
- - Any database
- - Kafka
-
+---
-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
{
@@ -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
diff --git a/apps/anti-fraud-service/.env.example b/apps/anti-fraud-service/.env.example
new file mode 100644
index 0000000000..7316732f6a
--- /dev/null
+++ b/apps/anti-fraud-service/.env.example
@@ -0,0 +1 @@
+KAFKA_BROKERS=localhost:9092
diff --git a/apps/anti-fraud-service/package.json b/apps/anti-fraud-service/package.json
new file mode 100644
index 0000000000..e8d43ca45e
--- /dev/null
+++ b/apps/anti-fraud-service/package.json
@@ -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"
+ }
+}
diff --git a/apps/anti-fraud-service/src/config.ts b/apps/anti-fraud-service/src/config.ts
new file mode 100644
index 0000000000..076354b001
--- /dev/null
+++ b/apps/anti-fraud-service/src/config.ts
@@ -0,0 +1,3 @@
+export const config = {
+ kafkaBrokers: (process.env.KAFKA_BROKERS || "localhost:9092").split(",")
+};
diff --git a/apps/anti-fraud-service/src/domain/antiFraudPolicy.ts b/apps/anti-fraud-service/src/domain/antiFraudPolicy.ts
new file mode 100644
index 0000000000..dd345396ff
--- /dev/null
+++ b/apps/anti-fraud-service/src/domain/antiFraudPolicy.ts
@@ -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" };
+}
diff --git a/apps/anti-fraud-service/src/main.ts b/apps/anti-fraud-service/src/main.ts
new file mode 100644
index 0000000000..bff0f75f17
--- /dev/null
+++ b/apps/anti-fraud-service/src/main.ts
@@ -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);
+});
diff --git a/apps/anti-fraud-service/src/messaging/consume-created.ts b/apps/anti-fraud-service/src/messaging/consume-created.ts
new file mode 100644
index 0000000000..5ea26996d7
--- /dev/null
+++ b/apps/anti-fraud-service/src/messaging/consume-created.ts
@@ -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(); // 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 };
+}
diff --git a/apps/anti-fraud-service/src/messaging/topics.ts b/apps/anti-fraud-service/src/messaging/topics.ts
new file mode 100644
index 0000000000..744aac3347
--- /dev/null
+++ b/apps/anti-fraud-service/src/messaging/topics.ts
@@ -0,0 +1,5 @@
+export const TOPICS = {
+ created: "transactions.created",
+ validated: "transactions.validated",
+ createdDlq: "transactions.created.dlq"
+} as const;
diff --git a/apps/anti-fraud-service/src/utils/logger.ts b/apps/anti-fraud-service/src/utils/logger.ts
new file mode 100644
index 0000000000..7e42a921b0
--- /dev/null
+++ b/apps/anti-fraud-service/src/utils/logger.ts
@@ -0,0 +1,2 @@
+import pino from "pino";
+export const logger = pino({ level: process.env.LOG_LEVEL || "info" });
diff --git a/apps/anti-fraud-service/tsconfig.json b/apps/anti-fraud-service/tsconfig.json
new file mode 100644
index 0000000000..93b32a3ab3
--- /dev/null
+++ b/apps/anti-fraud-service/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "outDir": "dist",
+ "strict": true,
+ "esModuleInterop": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/transaction-service/.env.example b/apps/transaction-service/.env.example
new file mode 100644
index 0000000000..e98ca1e4de
--- /dev/null
+++ b/apps/transaction-service/.env.example
@@ -0,0 +1,3 @@
+PORT=3000
+DATABASE_URL=postgresql://app:app@localhost:5432/yape?schema=public
+KAFKA_BROKERS=localhost:9092
diff --git a/apps/transaction-service/package.json b/apps/transaction-service/package.json
new file mode 100644
index 0000000000..06ce0bacb2
--- /dev/null
+++ b/apps/transaction-service/package.json
@@ -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"
+ }
+}
+
diff --git a/apps/transaction-service/prisma/schema.prisma b/apps/transaction-service/prisma/schema.prisma
new file mode 100644
index 0000000000..59627d100f
--- /dev/null
+++ b/apps/transaction-service/prisma/schema.prisma
@@ -0,0 +1,63 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+enum TransactionStatus {
+ pending
+ approved
+ rejected
+}
+
+enum OutboxStatus {
+ pending
+ sent
+ failed
+}
+
+model Transaction {
+ id String @id @default(uuid())
+ transactionExternalId String @unique
+ accountExternalIdDebit String
+ accountExternalIdCredit String
+ transferTypeId Int
+ value Decimal
+ status TransactionStatus @default(pending)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ version Int @default(0)
+
+ @@index([status])
+ @@index([createdAt])
+}
+
+model OutboxEvent {
+ id String @id // eventId (uuid)
+ aggregateId String
+ type String
+ payload Json
+ status OutboxStatus @default(pending)
+ attempts Int @default(0)
+ nextRetryAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([status, nextRetryAt])
+ @@index([aggregateId])
+}
+
+model ProcessedEvent {
+ eventId String @id
+ processedAt DateTime @default(now())
+}
+
+model IdempotencyKey {
+ key String @id
+ requestHash String
+ responseBody Json
+ createdAt DateTime @default(now())
+}
diff --git a/apps/transaction-service/src/app.ts b/apps/transaction-service/src/app.ts
new file mode 100644
index 0000000000..1cb6e629d9
--- /dev/null
+++ b/apps/transaction-service/src/app.ts
@@ -0,0 +1,20 @@
+import Fastify from "fastify";
+import { logger } from "./utils/logger";
+import { transactionsRoutes } from "./routes/transactions.routes";
+
+export function buildApp() {
+ const app = Fastify({
+ logger: false
+ });
+
+ app.get("/health", async () => ({ ok: true }));
+
+ app.setErrorHandler((err, req, reply) => {
+ logger.error({ err }, "Unhandled error");
+ reply.code(500).send({ error: "Internal Server Error" });
+ });
+
+ app.register(transactionsRoutes);
+
+ return app;
+}
diff --git a/apps/transaction-service/src/application/create-transaction.ts b/apps/transaction-service/src/application/create-transaction.ts
new file mode 100644
index 0000000000..30e6e5bcae
--- /dev/null
+++ b/apps/transaction-service/src/application/create-transaction.ts
@@ -0,0 +1,77 @@
+import { prisma } from "../db/prisma";
+import { randomUUID } from "crypto";
+import { TransactionCreateRequestSchema, TransactionCreatedEventSchema } from "@yape/contracts/src";
+import { requestHash } from "../utils/idempotency";
+
+export async function createTransaction(input: unknown, idempotencyKey?: string) {
+ const dto = TransactionCreateRequestSchema.parse(input);
+
+ if (idempotencyKey) {
+ const hash = requestHash(dto);
+ const found = await prisma.idempotencyKey.findUnique({ where: { key: idempotencyKey } });
+ if (found) {
+ if (found.requestHash !== hash) {
+ const err = new Error("Idempotency-Key reuse with different payload");
+ (err as any).statusCode = 409;
+ throw err;
+ }
+ return found.responseBody;
+ }
+ }
+
+ const transactionExternalId = randomUUID();
+ const correlationId = randomUUID();
+ const eventId = randomUUID();
+ const now = new Date();
+
+ const createdEvent = TransactionCreatedEventSchema.parse({
+ eventId,
+ correlationId,
+ occurredAt: now.toISOString(),
+ transactionExternalId,
+ accountExternalIdDebit: dto.accountExternalIdDebit,
+ accountExternalIdCredit: dto.accountExternalIdCredit,
+ transferTypeId: dto.tranferTypeId,
+ value: dto.value,
+ createdAt: now.toISOString()
+ });
+
+ const responseBody = {
+ transactionExternalId,
+ transactionStatus: { name: "pending" }
+ };
+
+ await prisma.$transaction(async (tx) => {
+ await tx.transaction.create({
+ data: {
+ transactionExternalId,
+ accountExternalIdDebit: dto.accountExternalIdDebit,
+ accountExternalIdCredit: dto.accountExternalIdCredit,
+ transferTypeId: dto.tranferTypeId,
+ value: dto.value.toString(),
+ status: "pending"
+ }
+ });
+
+ await tx.outboxEvent.create({
+ data: {
+ id: eventId,
+ aggregateId: transactionExternalId,
+ type: "TransactionCreated",
+ payload: createdEvent
+ }
+ });
+
+ if (idempotencyKey) {
+ await tx.idempotencyKey.create({
+ data: {
+ key: idempotencyKey,
+ requestHash: requestHash(dto),
+ responseBody
+ }
+ });
+ }
+ });
+
+ return responseBody;
+}
diff --git a/apps/transaction-service/src/application/get-transaction.ts b/apps/transaction-service/src/application/get-transaction.ts
new file mode 100644
index 0000000000..4b3be25825
--- /dev/null
+++ b/apps/transaction-service/src/application/get-transaction.ts
@@ -0,0 +1,18 @@
+import { prisma } from "../db/prisma";
+import { transferTypeName } from "../domain/transaction";
+
+export async function getTransaction(transactionExternalId: string) {
+ const tx = await prisma.transaction.findUnique({
+ where: { transactionExternalId }
+ });
+
+ if (!tx) return null;
+
+ return {
+ transactionExternalId: tx.transactionExternalId,
+ transactionType: { name: transferTypeName(tx.transferTypeId) },
+ transactionStatus: { name: tx.status },
+ value: Number(tx.value),
+ createdAt: tx.createdAt.toISOString()
+ };
+}
diff --git a/apps/transaction-service/src/config.ts b/apps/transaction-service/src/config.ts
new file mode 100644
index 0000000000..9e0a8db68f
--- /dev/null
+++ b/apps/transaction-service/src/config.ts
@@ -0,0 +1,5 @@
+export const config = {
+ port: Number(process.env.PORT || 3000),
+ databaseUrl: process.env.DATABASE_URL!,
+ kafkaBrokers: (process.env.KAFKA_BROKERS || "localhost:9092").split(",")
+};
diff --git a/apps/transaction-service/src/db/prisma.ts b/apps/transaction-service/src/db/prisma.ts
new file mode 100644
index 0000000000..901f3a0d96
--- /dev/null
+++ b/apps/transaction-service/src/db/prisma.ts
@@ -0,0 +1,3 @@
+import { PrismaClient } from "@prisma/client";
+
+export const prisma = new PrismaClient();
diff --git a/apps/transaction-service/src/domain/transaction.ts b/apps/transaction-service/src/domain/transaction.ts
new file mode 100644
index 0000000000..f46c219746
--- /dev/null
+++ b/apps/transaction-service/src/domain/transaction.ts
@@ -0,0 +1,4 @@
+export function transferTypeName(transferTypeId: number): string {
+ if (transferTypeId === 1) return "TRANSFER";
+ return `TYPE_${transferTypeId}`;
+}
diff --git a/apps/transaction-service/src/messaging/consume-validated.ts b/apps/transaction-service/src/messaging/consume-validated.ts
new file mode 100644
index 0000000000..8c1f164c32
--- /dev/null
+++ b/apps/transaction-service/src/messaging/consume-validated.ts
@@ -0,0 +1,66 @@
+import { createKafka, createConsumer, safePublish } from "@yape/kafka/src";
+import { config } from "../config";
+import { TOPICS } from "./topics";
+import { logger } from "../utils/logger";
+import { prisma } from "../db/prisma";
+import { TransactionValidatedEventSchema } from "@yape/contracts/src";
+
+export async function startValidatedConsumer() {
+ const kafka = createKafka({ clientId: "transaction-service", brokers: config.kafkaBrokers });
+ const consumer = await createConsumer(kafka, "transaction-service-updater");
+
+ await consumer.subscribe({ topic: TOPICS.validated, fromBeginning: true });
+
+ await consumer.run({
+ autoCommit: false,
+ eachMessage: async ({ topic, partition, message }) => {
+ const raw = message.value?.toString() || "";
+
+ try {
+ const parsed = TransactionValidatedEventSchema.parse(JSON.parse(raw));
+
+ // También idempotencia:
+ const already = await prisma.processedEvent.findUnique({ where: { eventId: parsed.eventId } });
+ if (already) {
+ await consumer.commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }]);
+ return;
+ }
+
+ // Solo actualiza si estaba pending (control de reprocesos)
+ await prisma.$transaction(async (tx) => {
+ await tx.transaction.updateMany({
+ where: { transactionExternalId: parsed.transactionExternalId, status: "pending" },
+ data: { status: parsed.status }
+ });
+
+ await tx.processedEvent.create({ data: { eventId: parsed.eventId } });
+ });
+
+ logger.info(
+ { transactionExternalId: parsed.transactionExternalId, status: parsed.status, eventId: parsed.eventId },
+ "Transaction updated"
+ );
+
+ await consumer.commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }]);
+ } catch (e: any) {
+ logger.error({ err: e, raw }, "Validated consumer error -> DLQ");
+
+ // DLQ por si no procesa el mensaje
+ try {
+ const producer = kafka.producer();
+ await producer.connect();
+ await safePublish(producer, TOPICS.validatedDlq, message.key?.toString() || "dlq", { raw, error: e?.message });
+ await producer.disconnect();
+ } catch {}
+
+ await consumer.commitOffsets([{ topic, partition, offset: (Number(message.offset) + 1).toString() }]);
+ }
+ }
+ });
+
+ const stop = async () => {
+ await consumer.disconnect();
+ };
+
+ return { stop };
+}
diff --git a/apps/transaction-service/src/messaging/publish-outbox.ts b/apps/transaction-service/src/messaging/publish-outbox.ts
new file mode 100644
index 0000000000..0e176f4b84
--- /dev/null
+++ b/apps/transaction-service/src/messaging/publish-outbox.ts
@@ -0,0 +1,66 @@
+import { prisma } from "../db/prisma";
+import { logger } from "../utils/logger";
+import { createKafka, createProducer, safePublish } from "@yape/kafka/src";
+import { TOPICS } from "./topics";
+import { config } from "../config";
+
+function backoffMs(attempt: number) {
+ const seq = [1000, 5000, 30000, 120000, 300000];
+ return seq[Math.min(attempt, seq.length - 1)];
+}
+
+export async function startOutboxPublisher() {
+ const kafka = createKafka({ clientId: "transaction-service", brokers: config.kafkaBrokers });
+ const producer = await createProducer(kafka);
+
+ const interval = setInterval(async () => {
+ try {
+ const now = new Date();
+
+ const batch = await prisma.outboxEvent.findMany({
+ where: {
+ status: "pending",
+ OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
+ },
+ orderBy: { createdAt: "asc" },
+ take: 50
+ });
+
+ for (const evt of batch) {
+ try {
+ await safePublish(producer, TOPICS.created, evt.aggregateId, evt.payload, {
+ "x-event-id": evt.id,
+ "x-event-type": evt.type
+ });
+
+ await prisma.outboxEvent.update({
+ where: { id: evt.id },
+ data: { status: "sent" }
+ });
+ } catch (e: any) {
+ const attempts = evt.attempts + 1;
+ const nextRetryAt = new Date(Date.now() + backoffMs(attempts));
+ await prisma.outboxEvent.update({
+ where: { id: evt.id },
+ data: {
+ attempts,
+ nextRetryAt,
+ status: attempts >= 8 ? "failed" : "pending"
+ }
+ });
+
+ logger.error({ err: e, eventId: evt.id, attempts }, "Outbox publish failed");
+ }
+ }
+ } catch (e: any) {
+ logger.error({ err: e }, "Outbox loop error");
+ }
+ }, 1000);
+
+ const stop = async () => {
+ clearInterval(interval);
+ await producer.disconnect();
+ };
+
+ return { stop };
+}
diff --git a/apps/transaction-service/src/messaging/topics.ts b/apps/transaction-service/src/messaging/topics.ts
new file mode 100644
index 0000000000..e9e24dc300
--- /dev/null
+++ b/apps/transaction-service/src/messaging/topics.ts
@@ -0,0 +1,5 @@
+export const TOPICS = {
+ created: "transactions.created",
+ validated: "transactions.validated",
+ validatedDlq: "transactions.validated.dlq"
+} as const;
diff --git a/apps/transaction-service/src/routes/transactions.routes.ts b/apps/transaction-service/src/routes/transactions.routes.ts
new file mode 100644
index 0000000000..7f0a4def7a
--- /dev/null
+++ b/apps/transaction-service/src/routes/transactions.routes.ts
@@ -0,0 +1,26 @@
+import { FastifyInstance } from "fastify";
+import { createTransaction } from "../application/create-transaction";
+import { getTransaction } from "../application/get-transaction";
+
+export async function transactionsRoutes(app: FastifyInstance) {
+ app.post("/transactions", async (req, reply) => {
+ const idempotencyKey = (req.headers["idempotency-key"] as string | undefined)?.trim();
+
+ try {
+ const result = await createTransaction(req.body, idempotencyKey);
+ return reply.code(201).send(result);
+ } catch (e: any) {
+ const status = e?.statusCode || 400;
+ return reply.code(status).send({ error: e.message });
+ }
+ });
+
+ app.get("/transactions/:transactionExternalId", async (req, reply) => {
+ const { transactionExternalId } = req.params as any;
+
+ const tx = await getTransaction(transactionExternalId);
+ if (!tx) return reply.code(404).send({ error: "Transaction not found" });
+
+ return reply.send(tx);
+ });
+}
diff --git a/apps/transaction-service/src/server.ts b/apps/transaction-service/src/server.ts
new file mode 100644
index 0000000000..703dd8f17f
--- /dev/null
+++ b/apps/transaction-service/src/server.ts
@@ -0,0 +1,33 @@
+import { config } from "./config";
+import { buildApp } from "./app";
+import { logger } from "./utils/logger";
+import { startOutboxPublisher } from "./messaging/publish-outbox";
+import { startValidatedConsumer } from "./messaging/consume-validated";
+import { prisma } from "./db/prisma";
+
+async function main() {
+ const app = buildApp();
+
+ const outbox = await startOutboxPublisher();
+ const validated = await startValidatedConsumer();
+
+ await app.listen({ port: config.port, host: "0.0.0.0" });
+ logger.info({ port: config.port }, "Transaction service started");
+
+ const shutdown = async () => {
+ logger.info("Shutting down...");
+ await outbox.stop();
+ await validated.stop();
+ await app.close();
+ await prisma.$disconnect();
+ process.exit(0);
+ };
+
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+main().catch((e) => {
+ logger.error({ err: e }, "Fatal error");
+ process.exit(1);
+});
diff --git a/apps/transaction-service/src/utils/idempotency.ts b/apps/transaction-service/src/utils/idempotency.ts
new file mode 100644
index 0000000000..52bc5696e5
--- /dev/null
+++ b/apps/transaction-service/src/utils/idempotency.ts
@@ -0,0 +1,10 @@
+import crypto from "crypto";
+import { stableJson } from "@yape/contracts/src";
+
+export function sha256(input: string) {
+ return crypto.createHash("sha256").update(input).digest("hex");
+}
+
+export function requestHash(body: unknown) {
+ return sha256(stableJson(body));
+}
diff --git a/apps/transaction-service/src/utils/logger.ts b/apps/transaction-service/src/utils/logger.ts
new file mode 100644
index 0000000000..981bd4eacc
--- /dev/null
+++ b/apps/transaction-service/src/utils/logger.ts
@@ -0,0 +1,5 @@
+import pino from "pino";
+
+export const logger = pino({
+ level: process.env.LOG_LEVEL || "info"
+});
diff --git a/docker-compose.yml b/docker-compose.yml
index 0e8807f21c..b5905ca1f3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,25 +1,42 @@
-version: "3.7"
services:
postgres:
- image: postgres:14
+ image: postgres:16
+ environment:
+ POSTGRES_USER: app
+ POSTGRES_PASSWORD: app
+ POSTGRES_DB: yape
ports:
- "5432:5432"
- environment:
- - POSTGRES_USER=postgres
- - POSTGRES_PASSWORD=postgres
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U app -d yape"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
zookeeper:
- image: confluentinc/cp-zookeeper:5.5.3
+ image: confluentinc/cp-zookeeper:7.6.1
environment:
ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
+ ports:
+ - "2181:2181"
+
kafka:
- image: confluentinc/cp-enterprise-kafka:5.5.3
- depends_on: [zookeeper]
+ image: confluentinc/cp-kafka:7.6.1
+ depends_on:
+ - zookeeper
+ ports:
+ - "9092:9092"
environment:
- KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
- KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: "zookeeper:2181"
+
+
+ KAFKA_LISTENERS: "PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092"
+ KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092"
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT"
+ KAFKA_INTER_BROKER_LISTENER_NAME: "PLAINTEXT"
+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
- KAFKA_JMX_PORT: 9991
- ports:
- - 9092:9092
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..8ff13f8628
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,10 @@
+{
+ "name": "yape-code-challenge",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "yape-code-challenge"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000..176640d15c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "yape-code-challenge",
+ "private": true,
+ "packageManager": "pnpm@9.0.0",
+ "scripts": {
+ "dev:infra": "docker compose up -d",
+ "dev:transaction": "pnpm -C apps/transaction-service dev",
+ "dev:antifraud": "pnpm -C apps/anti-fraud-service dev",
+ "dev:all": "pnpm -r dev",
+ "db:push": "pnpm -C apps/transaction-service db:push",
+ "db:studio": "pnpm -C apps/transaction-service db:studio"
+ }
+}
diff --git a/packages/contracts/package-lock.json b/packages/contracts/package-lock.json
new file mode 100644
index 0000000000..2d68ea55df
--- /dev/null
+++ b/packages/contracts/package-lock.json
@@ -0,0 +1,24 @@
+{
+ "name": "@yape/contracts",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@yape/contracts",
+ "version": "1.0.0",
+ "dependencies": {
+ "zod": "^3.24.1"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
new file mode 100644
index 0000000000..6c8933780e
--- /dev/null
+++ b/packages/contracts/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@yape/contracts",
+ "version": "1.0.0",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "dev": "tsc -p tsconfig.json --watch"
+ },
+ "dependencies": {
+ "zod": "^3.24.1"
+ }
+}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
new file mode 100644
index 0000000000..fe63da39c8
--- /dev/null
+++ b/packages/contracts/src/index.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+
+export const Guid = z.string().uuid();
+
+export const TransactionCreateRequestSchema = z.object({
+ accountExternalIdDebit: Guid,
+ accountExternalIdCredit: Guid,
+ tranferTypeId: z.number().int().positive(),
+ value: z.number().positive()
+});
+
+export type TransactionCreateRequest = z.infer;
+
+export const TransactionCreatedEventSchema = z.object({
+ eventId: Guid,
+ correlationId: Guid,
+ occurredAt: z.string().datetime(),
+ transactionExternalId: Guid,
+ accountExternalIdDebit: Guid,
+ accountExternalIdCredit: Guid,
+ transferTypeId: z.number().int().positive(),
+ value: z.number().positive(),
+ createdAt: z.string().datetime()
+});
+
+export type TransactionCreatedEvent = z.infer;
+
+export const TransactionValidatedEventSchema = z.object({
+ eventId: Guid,
+ correlationId: Guid,
+ occurredAt: z.string().datetime(),
+ transactionExternalId: Guid,
+ status: z.enum(["approved", "rejected"]),
+ reason: z.string().min(1),
+ validatedAt: z.string().datetime()
+});
+
+export type TransactionValidatedEvent = z.infer;
+
+export function stableJson(obj: unknown): string {
+ // Para hashing determinístico de requests (idempotencia)
+ return JSON.stringify(obj, Object.keys(obj as any).sort());
+}
diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json
new file mode 100644
index 0000000000..33c1c976b3
--- /dev/null
+++ b/packages/contracts/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "declaration": true,
+ "outDir": "dist",
+ "strict": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/kafka/package-lock.json b/packages/kafka/package-lock.json
new file mode 100644
index 0000000000..78527c7375
--- /dev/null
+++ b/packages/kafka/package-lock.json
@@ -0,0 +1,44 @@
+{
+ "name": "@yape/kafka",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@yape/kafka",
+ "version": "1.0.0",
+ "dependencies": {
+ "kafkajs": "^2.2.4"
+ },
+ "devDependencies": {
+ "@types/node": "^25.0.8"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.0.8",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
+ "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/kafkajs": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz",
+ "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/packages/kafka/package.json b/packages/kafka/package.json
new file mode 100644
index 0000000000..b24e918e8d
--- /dev/null
+++ b/packages/kafka/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@yape/kafka",
+ "version": "1.0.0",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "dev": "tsc -p tsconfig.json --watch"
+ },
+ "dependencies": {
+ "kafkajs": "^2.2.4"
+ },
+ "devDependencies": {
+ "@types/node": "^25.0.8"
+ }
+}
diff --git a/packages/kafka/src/index.ts b/packages/kafka/src/index.ts
new file mode 100644
index 0000000000..de6cf8f4eb
--- /dev/null
+++ b/packages/kafka/src/index.ts
@@ -0,0 +1,47 @@
+import { Kafka, logLevel, Producer, Consumer } from "kafkajs";
+import { Buffer } from "buffer";
+
+
+export type KafkaClientOptions = {
+ clientId: string;
+ brokers: string[];
+};
+
+export function createKafka({ clientId, brokers }: KafkaClientOptions) {
+ return new Kafka({
+ clientId,
+ brokers,
+ logLevel: logLevel.NOTHING
+ });
+}
+
+export async function createProducer(kafka: Kafka): Promise {
+ const producer = kafka.producer();
+ await producer.connect();
+ return producer;
+}
+
+export async function createConsumer(kafka: Kafka, groupId: string): Promise {
+ const consumer = kafka.consumer({ groupId });
+ await consumer.connect();
+ return consumer;
+}
+
+export async function safePublish(
+ producer: Producer,
+ topic: string,
+ key: string,
+ value: unknown,
+ headers?: Record
+) {
+ await producer.send({
+ topic,
+ messages: [
+ {
+ key,
+ value: JSON.stringify(value),
+ headers: headers ? Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, Buffer.from(v)])) : undefined
+ }
+ ]
+ });
+}
diff --git a/packages/kafka/tsconfig.json b/packages/kafka/tsconfig.json
new file mode 100644
index 0000000000..33c1c976b3
--- /dev/null
+++ b/packages/kafka/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "declaration": true,
+ "outDir": "dist",
+ "strict": true
+ },
+ "include": ["src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000000..55e5600d9d
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,820 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .: {}
+
+ apps/anti-fraud-service:
+ dependencies:
+ '@yape/contracts':
+ specifier: workspace:*
+ version: link:../../packages/contracts
+ '@yape/kafka':
+ specifier: workspace:*
+ version: link:../../packages/kafka
+ pino:
+ specifier: ^9.3.2
+ version: 9.14.0
+ devDependencies:
+ tsx:
+ specifier: ^4.19.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.5.4
+ version: 5.9.3
+
+ apps/transaction-service:
+ dependencies:
+ '@prisma/client':
+ specifier: ^5.19.1
+ version: 5.22.0(prisma@5.22.0)
+ '@yape/contracts':
+ specifier: workspace:*
+ version: link:../../packages/contracts
+ '@yape/kafka':
+ specifier: workspace:*
+ version: link:../../packages/kafka
+ fastify:
+ specifier: ^4.28.1
+ version: 4.29.1
+ pino:
+ specifier: ^9.3.2
+ version: 9.14.0
+ devDependencies:
+ prisma:
+ specifier: ^5.22.0
+ version: 5.22.0
+ tsx:
+ specifier: ^4.19.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.5.4
+ version: 5.9.3
+
+ packages/contracts:
+ dependencies:
+ zod:
+ specifier: ^3.24.1
+ version: 3.25.76
+
+ packages/kafka:
+ dependencies:
+ kafkajs:
+ specifier: ^2.2.4
+ version: 2.2.4
+ devDependencies:
+ '@types/node':
+ specifier: ^25.0.8
+ version: 25.0.8
+
+packages:
+
+ '@esbuild/aix-ppc64@0.27.2':
+ resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.2':
+ resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.2':
+ resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.2':
+ resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.2':
+ resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.2':
+ resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.2':
+ resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.2':
+ resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.2':
+ resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.2':
+ resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.2':
+ resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.2':
+ resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.2':
+ resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.2':
+ resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.2':
+ resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.2':
+ resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.2':
+ resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.2':
+ resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.2':
+ resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.2':
+ resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.2':
+ resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.2':
+ resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@fastify/ajv-compiler@3.6.0':
+ resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==}
+
+ '@fastify/error@3.4.1':
+ resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==}
+
+ '@fastify/fast-json-stringify-compiler@4.3.0':
+ resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
+
+ '@fastify/merge-json-schemas@0.1.1':
+ resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
+
+ '@pinojs/redact@0.4.0':
+ resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+
+ '@prisma/client@5.22.0':
+ resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
+ engines: {node: '>=16.13'}
+ peerDependencies:
+ prisma: '*'
+ peerDependenciesMeta:
+ prisma:
+ optional: true
+
+ '@prisma/debug@5.22.0':
+ resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==}
+
+ '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2':
+ resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==}
+
+ '@prisma/engines@5.22.0':
+ resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==}
+
+ '@prisma/fetch-engine@5.22.0':
+ resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==}
+
+ '@prisma/get-platform@5.22.0':
+ resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
+
+ '@types/node@25.0.8':
+ resolution: {integrity: sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==}
+
+ abstract-logging@2.0.1:
+ resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
+
+ ajv-formats@2.1.1:
+ resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv@8.17.1:
+ resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+
+ atomic-sleep@1.0.0:
+ resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
+ engines: {node: '>=8.0.0'}
+
+ avvio@8.4.0:
+ resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
+ esbuild@0.27.2:
+ resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fast-content-type-parse@1.1.0:
+ resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==}
+
+ fast-decode-uri-component@1.0.1:
+ resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stringify@5.16.1:
+ resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==}
+
+ fast-querystring@1.1.2:
+ resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
+
+ fast-uri@2.4.0:
+ resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==}
+
+ fast-uri@3.1.0:
+ resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+
+ fastify@4.29.1:
+ resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
+
+ fastq@1.20.1:
+ resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
+
+ find-my-way@8.2.2:
+ resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==}
+ engines: {node: '>=14'}
+
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ get-tsconfig@4.13.0:
+ resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
+
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
+ json-schema-ref-resolver@1.0.1:
+ resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ kafkajs@2.2.4:
+ resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
+ engines: {node: '>=14.0.0'}
+
+ light-my-request@5.14.0:
+ resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==}
+
+ on-exit-leak-free@2.1.2:
+ resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
+ engines: {node: '>=14.0.0'}
+
+ pino-abstract-transport@2.0.0:
+ resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
+
+ pino-std-serializers@7.1.0:
+ resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
+
+ pino@9.14.0:
+ resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
+ hasBin: true
+
+ prisma@5.22.0:
+ resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
+ engines: {node: '>=16.13'}
+ hasBin: true
+
+ process-warning@3.0.0:
+ resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
+
+ process-warning@5.0.0:
+ resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
+
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ quick-format-unescaped@4.0.4:
+ resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+
+ real-require@0.2.0:
+ resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
+ engines: {node: '>= 12.13.0'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ ret@0.4.3:
+ resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
+ engines: {node: '>=10'}
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rfdc@1.4.1:
+ resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+
+ safe-regex2@3.1.0:
+ resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==}
+
+ safe-stable-stringify@2.5.0:
+ resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
+ engines: {node: '>=10'}
+
+ secure-json-parse@2.7.0:
+ resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
+
+ semver@7.7.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ sonic-boom@4.2.0:
+ resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
+
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
+ thread-stream@3.1.0:
+ resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
+
+ toad-cache@3.7.0:
+ resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
+ engines: {node: '>=12'}
+
+ tsx@4.21.0:
+ resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ zod@3.25.76:
+ resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+ '@esbuild/aix-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm@0.27.2':
+ optional: true
+
+ '@esbuild/android-x64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.2':
+ optional: true
+
+ '@fastify/ajv-compiler@3.6.0':
+ dependencies:
+ ajv: 8.17.1
+ ajv-formats: 2.1.1(ajv@8.17.1)
+ fast-uri: 2.4.0
+
+ '@fastify/error@3.4.1': {}
+
+ '@fastify/fast-json-stringify-compiler@4.3.0':
+ dependencies:
+ fast-json-stringify: 5.16.1
+
+ '@fastify/merge-json-schemas@0.1.1':
+ dependencies:
+ fast-deep-equal: 3.1.3
+
+ '@pinojs/redact@0.4.0': {}
+
+ '@prisma/client@5.22.0(prisma@5.22.0)':
+ optionalDependencies:
+ prisma: 5.22.0
+
+ '@prisma/debug@5.22.0': {}
+
+ '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {}
+
+ '@prisma/engines@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+ '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
+ '@prisma/fetch-engine': 5.22.0
+ '@prisma/get-platform': 5.22.0
+
+ '@prisma/fetch-engine@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+ '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
+ '@prisma/get-platform': 5.22.0
+
+ '@prisma/get-platform@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+
+ '@types/node@25.0.8':
+ dependencies:
+ undici-types: 7.16.0
+
+ abstract-logging@2.0.1: {}
+
+ ajv-formats@2.1.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+
+ ajv-formats@3.0.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+
+ ajv@8.17.1:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ atomic-sleep@1.0.0: {}
+
+ avvio@8.4.0:
+ dependencies:
+ '@fastify/error': 3.4.1
+ fastq: 1.20.1
+
+ cookie@0.7.2: {}
+
+ esbuild@0.27.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.2
+ '@esbuild/android-arm': 0.27.2
+ '@esbuild/android-arm64': 0.27.2
+ '@esbuild/android-x64': 0.27.2
+ '@esbuild/darwin-arm64': 0.27.2
+ '@esbuild/darwin-x64': 0.27.2
+ '@esbuild/freebsd-arm64': 0.27.2
+ '@esbuild/freebsd-x64': 0.27.2
+ '@esbuild/linux-arm': 0.27.2
+ '@esbuild/linux-arm64': 0.27.2
+ '@esbuild/linux-ia32': 0.27.2
+ '@esbuild/linux-loong64': 0.27.2
+ '@esbuild/linux-mips64el': 0.27.2
+ '@esbuild/linux-ppc64': 0.27.2
+ '@esbuild/linux-riscv64': 0.27.2
+ '@esbuild/linux-s390x': 0.27.2
+ '@esbuild/linux-x64': 0.27.2
+ '@esbuild/netbsd-arm64': 0.27.2
+ '@esbuild/netbsd-x64': 0.27.2
+ '@esbuild/openbsd-arm64': 0.27.2
+ '@esbuild/openbsd-x64': 0.27.2
+ '@esbuild/openharmony-arm64': 0.27.2
+ '@esbuild/sunos-x64': 0.27.2
+ '@esbuild/win32-arm64': 0.27.2
+ '@esbuild/win32-ia32': 0.27.2
+ '@esbuild/win32-x64': 0.27.2
+
+ fast-content-type-parse@1.1.0: {}
+
+ fast-decode-uri-component@1.0.1: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stringify@5.16.1:
+ dependencies:
+ '@fastify/merge-json-schemas': 0.1.1
+ ajv: 8.17.1
+ ajv-formats: 3.0.1(ajv@8.17.1)
+ fast-deep-equal: 3.1.3
+ fast-uri: 2.4.0
+ json-schema-ref-resolver: 1.0.1
+ rfdc: 1.4.1
+
+ fast-querystring@1.1.2:
+ dependencies:
+ fast-decode-uri-component: 1.0.1
+
+ fast-uri@2.4.0: {}
+
+ fast-uri@3.1.0: {}
+
+ fastify@4.29.1:
+ dependencies:
+ '@fastify/ajv-compiler': 3.6.0
+ '@fastify/error': 3.4.1
+ '@fastify/fast-json-stringify-compiler': 4.3.0
+ abstract-logging: 2.0.1
+ avvio: 8.4.0
+ fast-content-type-parse: 1.1.0
+ fast-json-stringify: 5.16.1
+ find-my-way: 8.2.2
+ light-my-request: 5.14.0
+ pino: 9.14.0
+ process-warning: 3.0.0
+ proxy-addr: 2.0.7
+ rfdc: 1.4.1
+ secure-json-parse: 2.7.0
+ semver: 7.7.3
+ toad-cache: 3.7.0
+
+ fastq@1.20.1:
+ dependencies:
+ reusify: 1.1.0
+
+ find-my-way@8.2.2:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-querystring: 1.1.2
+ safe-regex2: 3.1.0
+
+ forwarded@0.2.0: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ get-tsconfig@4.13.0:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ ipaddr.js@1.9.1: {}
+
+ json-schema-ref-resolver@1.0.1:
+ dependencies:
+ fast-deep-equal: 3.1.3
+
+ json-schema-traverse@1.0.0: {}
+
+ kafkajs@2.2.4: {}
+
+ light-my-request@5.14.0:
+ dependencies:
+ cookie: 0.7.2
+ process-warning: 3.0.0
+ set-cookie-parser: 2.7.2
+
+ on-exit-leak-free@2.1.2: {}
+
+ pino-abstract-transport@2.0.0:
+ dependencies:
+ split2: 4.2.0
+
+ pino-std-serializers@7.1.0: {}
+
+ pino@9.14.0:
+ dependencies:
+ '@pinojs/redact': 0.4.0
+ atomic-sleep: 1.0.0
+ on-exit-leak-free: 2.1.2
+ pino-abstract-transport: 2.0.0
+ pino-std-serializers: 7.1.0
+ process-warning: 5.0.0
+ quick-format-unescaped: 4.0.4
+ real-require: 0.2.0
+ safe-stable-stringify: 2.5.0
+ sonic-boom: 4.2.0
+ thread-stream: 3.1.0
+
+ prisma@5.22.0:
+ dependencies:
+ '@prisma/engines': 5.22.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ process-warning@3.0.0: {}
+
+ process-warning@5.0.0: {}
+
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ quick-format-unescaped@4.0.4: {}
+
+ real-require@0.2.0: {}
+
+ require-from-string@2.0.2: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ ret@0.4.3: {}
+
+ reusify@1.1.0: {}
+
+ rfdc@1.4.1: {}
+
+ safe-regex2@3.1.0:
+ dependencies:
+ ret: 0.4.3
+
+ safe-stable-stringify@2.5.0: {}
+
+ secure-json-parse@2.7.0: {}
+
+ semver@7.7.3: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ sonic-boom@4.2.0:
+ dependencies:
+ atomic-sleep: 1.0.0
+
+ split2@4.2.0: {}
+
+ thread-stream@3.1.0:
+ dependencies:
+ real-require: 0.2.0
+
+ toad-cache@3.7.0: {}
+
+ tsx@4.21.0:
+ dependencies:
+ esbuild: 0.27.2
+ get-tsconfig: 4.13.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ typescript@5.9.3: {}
+
+ undici-types@7.16.0: {}
+
+ zod@3.25.76: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000000..3e712d3ca9
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - "apps/*"
+ - "packages/*"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000000..59627d100f
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,63 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+enum TransactionStatus {
+ pending
+ approved
+ rejected
+}
+
+enum OutboxStatus {
+ pending
+ sent
+ failed
+}
+
+model Transaction {
+ id String @id @default(uuid())
+ transactionExternalId String @unique
+ accountExternalIdDebit String
+ accountExternalIdCredit String
+ transferTypeId Int
+ value Decimal
+ status TransactionStatus @default(pending)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ version Int @default(0)
+
+ @@index([status])
+ @@index([createdAt])
+}
+
+model OutboxEvent {
+ id String @id // eventId (uuid)
+ aggregateId String
+ type String
+ payload Json
+ status OutboxStatus @default(pending)
+ attempts Int @default(0)
+ nextRetryAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([status, nextRetryAt])
+ @@index([aggregateId])
+}
+
+model ProcessedEvent {
+ eventId String @id
+ processedAt DateTime @default(now())
+}
+
+model IdempotencyKey {
+ key String @id
+ requestHash String
+ responseBody Json
+ createdAt DateTime @default(now())
+}
diff --git a/script.json b/script.json
new file mode 100644
index 0000000000..68087f17a8
--- /dev/null
+++ b/script.json
@@ -0,0 +1,5 @@
+{
+ "scripts": {
+ "build": "pnpm -r build"
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000000..b42f16b8c0
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+
+ "types": ["node"]
+ }
+}