diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4e4ed36..b9b5795 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_FILTER } from '@nestjs/core'; import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -15,6 +15,7 @@ import { TransactionsModule } from './transactions/transactions.module'; import { WorkerModule } from './modules/worker/worker.module'; import { WebhookModule } from './webhooks/webhook.module'; import { PaymentsModule } from './payments/payments.module'; +import { PrismaClientExceptionFilter } from './prisma/prisma-exception.filter'; @Module({ imports: [ @@ -47,6 +48,10 @@ import { PaymentsModule } from './payments/payments.module'; provide: APP_GUARD, useClass: ThrottlerRedisGuard, }, + { + provide: APP_FILTER, + useClass: PrismaClientExceptionFilter, + }, ], }) export class AppModule {} diff --git a/apps/api/src/prisma/prisma-exception.filter.ts b/apps/api/src/prisma/prisma-exception.filter.ts new file mode 100644 index 0000000..38c05ab --- /dev/null +++ b/apps/api/src/prisma/prisma-exception.filter.ts @@ -0,0 +1,36 @@ +import { Catch, ExceptionFilter, ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Response } from 'express'; + +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaClientExceptionFilter implements ExceptionFilter { + catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + let status: HttpStatus; + let message: string; + + switch (exception.code) { + case 'P2002': + status = HttpStatus.CONFLICT; + const target = exception.meta?.target as string[]; + message = `Unique constraint failed on fields: ${target.join(', ')}`; + break; + case 'P2025': + status = HttpStatus.NOT_FOUND; + message = exception.message || 'Record not found'; + break; + default: + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = 'Internal server error'; + break; + } + + response.status(status).json({ + statusCode: status, + message, + error: HttpStatus[status], + }); + } +} diff --git a/packages/database/migrations/migrations/202606292030_customers/migration.sql b/packages/database/migrations/migrations/202606292030_customers/migration.sql new file mode 100644 index 0000000..04452c5 --- /dev/null +++ b/packages/database/migrations/migrations/202606292030_customers/migration.sql @@ -0,0 +1,32 @@ +-- CreateEnum +CREATE TYPE "CustomerStatus" AS ENUM ('NEEDS_INFO', 'PROCESSING', 'APPROVED', 'REJECTED'); + +-- CreateTable +CREATE TABLE "customers" ( + "id" UUID NOT NULL, + "merchant_id" UUID NOT NULL, + "first_name" TEXT, + "last_name" TEXT, + "email" TEXT, + "phone_number" TEXT, + "country_code" TEXT, + "bank_account_number" TEXT, + "bank_routing_number" TEXT, + "date_of_birth" TEXT, + "street_address" TEXT, + "city" TEXT, + "region" TEXT, + "postal_code" TEXT, + "status" "CustomerStatus" NOT NULL DEFAULT 'NEEDS_INFO', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "customers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "customers_merchant_id_status_idx" ON "customers"("merchant_id", "status"); + +-- AddForeignKey +ALTER TABLE "customers" ADD CONSTRAINT "customers_merchant_id_fkey" FOREIGN KEY ("merchant_id") REFERENCES "merchants"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/migrations/schema.prisma b/packages/database/migrations/schema.prisma index a515a75..eddaebf 100644 --- a/packages/database/migrations/schema.prisma +++ b/packages/database/migrations/schema.prisma @@ -13,6 +13,13 @@ enum KycStatus { REJECTED } +enum CustomerStatus { + NEEDS_INFO + PROCESSING + APPROVED + REJECTED +} + enum PaymentIntentStatus { PENDING DETECTED @@ -33,10 +40,37 @@ model Merchant { webhookEvents WebhookEvent[] apiKeys ApiKey[] auditLogs AuditLog[] + customers Customer[] @@map("merchants") } +model Customer { + id String @id @default(uuid()) @db.Uuid + merchantId String @map("merchant_id") @db.Uuid + firstName String? @map("first_name") + lastName String? @map("last_name") + email String? + phoneNumber String? @map("phone_number") + countryCode String? @map("country_code") + bankAccountNumber String? @map("bank_account_number") + bankRoutingNumber String? @map("bank_routing_number") + dateOfBirth String? @map("date_of_birth") + streetAddress String? @map("street_address") + city String? + region String? + postalCode String? @map("postal_code") + status CustomerStatus @default(NEEDS_INFO) + createdAt DateTime @default(now()) @map("created_at") + deletedAt DateTime? @map("deleted_at") + updatedAt DateTime @updatedAt @map("updated_at") + + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + + @@index([merchantId, status]) + @@map("customers") +} + model PaymentIntent { id String @id @default(uuid()) @db.Uuid merchantId String @map("merchant_id") @db.Uuid