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. -
    -
  1. pending
  2. -
  3. approved
  4. -
  5. rejected
  6. -
+### 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 -
    -
  1. Node. You can use any framework you want (i.e. Nestjs with an ORM like TypeOrm or Prisma)
  2. -
  3. Any database
  4. -
  5. Kafka
  6. -
+--- -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"] + } +}