From 107400294576993d47f7991595a6cb33ac7ac958 Mon Sep 17 00:00:00 2001 From: ACOB-DEV Date: Thu, 25 Jun 2026 00:06:01 +0100 Subject: [PATCH] feat: add routes-d api key kyc and reconciliation endpoints --- app/api/routes-d/_shared/auth.ts | 21 +++ app/api/routes-d/_shared/webhook-events.ts | 21 +++ app/api/routes-d/api-keys/route.ts | 103 +++++++++++++ app/api/routes-d/kyc/source-of-funds/route.ts | 85 +++++++++++ .../routes-d/reconciliation/match/route.ts | 135 ++++++++++++++++++ .../routes-d/webhooks/event-catalog/route.ts | 21 +++ .../migration.sql | 22 +++ prisma/schema.prisma | 30 +++- tests/api.routes-d.api-keys.test.ts | 87 +++++++++++ .../api.routes-d.kyc.source-of-funds.test.ts | 81 +++++++++++ .../api.routes-d.reconciliation.match.test.ts | 99 +++++++++++++ ...pi.routes-d.webhooks.event-catalog.test.ts | 49 +++++++ 12 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 app/api/routes-d/_shared/auth.ts create mode 100644 app/api/routes-d/_shared/webhook-events.ts create mode 100644 app/api/routes-d/api-keys/route.ts create mode 100644 app/api/routes-d/kyc/source-of-funds/route.ts create mode 100644 app/api/routes-d/reconciliation/match/route.ts create mode 100644 app/api/routes-d/webhooks/event-catalog/route.ts create mode 100644 prisma/migrations/20260624000000_add_source_of_funds_declaration/migration.sql create mode 100644 tests/api.routes-d.api-keys.test.ts create mode 100644 tests/api.routes-d.kyc.source-of-funds.test.ts create mode 100644 tests/api.routes-d.reconciliation.match.test.ts create mode 100644 tests/api.routes-d.webhooks.event-catalog.test.ts diff --git a/app/api/routes-d/_shared/auth.ts b/app/api/routes-d/_shared/auth.ts new file mode 100644 index 00000000..0b200005 --- /dev/null +++ b/app/api/routes-d/_shared/auth.ts @@ -0,0 +1,21 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/lib/db' +import { verifyAuthToken } from '@/lib/auth' + +export type RoutesDUser = { + id: string + role: string +} + +export async function getAuthenticatedUser(request: NextRequest): Promise { + const authToken = request.headers.get('authorization')?.replace('Bearer ', '') + if (!authToken) return null + + const claims = await verifyAuthToken(authToken) + if (!claims) return null + + return prisma.user.findUnique({ + where: { privyId: claims.userId }, + select: { id: true, role: true }, + }) +} diff --git a/app/api/routes-d/_shared/webhook-events.ts b/app/api/routes-d/_shared/webhook-events.ts new file mode 100644 index 00000000..b926d666 --- /dev/null +++ b/app/api/routes-d/_shared/webhook-events.ts @@ -0,0 +1,21 @@ +export type WebhookEventDefinition = { + type: string + category: string + description: string +} + +export const WEBHOOK_EVENT_CATALOG: WebhookEventDefinition[] = [ + { type: 'invoice.created', category: 'invoice', description: 'A new invoice was created.' }, + { type: 'invoice.paid', category: 'invoice', description: 'An invoice was marked as paid.' }, + { type: 'invoice.overdue', category: 'invoice', description: 'An invoice became overdue.' }, + { type: 'invoice.cancelled', category: 'invoice', description: 'An invoice was cancelled.' }, + { type: 'transfer.completed', category: 'transfer', description: 'A transfer completed successfully.' }, + { type: 'transfer.failed', category: 'transfer', description: 'A transfer failed.' }, + { type: 'withdrawal.completed', category: 'withdrawal', description: 'A withdrawal completed successfully.' }, + { type: 'withdrawal.failed', category: 'withdrawal', description: 'A withdrawal failed.' }, + { type: 'kyc.submitted', category: 'kyc', description: 'A KYC application was submitted.' }, + { type: 'kyc.approved', category: 'kyc', description: 'A KYC application was approved.' }, + { type: 'kyc.rejected', category: 'kyc', description: 'A KYC application was rejected.' }, + { type: 'api_key.created', category: 'api_key', description: 'An API key was created.' }, + { type: 'reconciliation.matched', category: 'reconciliation', description: 'A transaction was matched to an invoice.' }, +] diff --git a/app/api/routes-d/api-keys/route.ts b/app/api/routes-d/api-keys/route.ts new file mode 100644 index 00000000..fb986176 --- /dev/null +++ b/app/api/routes-d/api-keys/route.ts @@ -0,0 +1,103 @@ +import crypto from 'crypto' +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/lib/db' +import { logger } from '../_shared/logger' +import { getAuthenticatedUser } from '../_shared/auth' + +const CreateApiKeySchema = z.object({ + name: z.string().trim().min(1).max(100), +}) + +type ApiKeyDelegate = { + findMany: (args: Record) => Promise>> + create: (args: Record) => Promise> +} + +function getApiKeyDelegate(): ApiKeyDelegate { + return (prisma as unknown as { apiKey: ApiKeyDelegate }).apiKey +} + +export async function GET(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const apiKeys = await getApiKeyDelegate().findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + keyHint: true, + isActive: true, + lastUsedAt: true, + createdAt: true, + updatedAt: true, + }, + }) + + return NextResponse.json({ apiKeys }) + } catch (error) { + logger.error({ err: error }, 'GET /api/routes-d/api-keys error') + return NextResponse.json({ error: 'Failed to list API keys' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + body = {} + } + + const parsed = CreateApiKeySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.format() }, + { status: 400 }, + ) + } + + const rawKey = `rk_${crypto.randomBytes(24).toString('hex')}` + const keyHint = rawKey.slice(-6) + const created = await getApiKeyDelegate().create({ + data: { + userId: user.id, + name: parsed.data.name, + keyHint, + hashedKey: crypto.createHash('sha256').update(rawKey).digest('hex'), + isActive: true, + }, + select: { + id: true, + name: true, + keyHint: true, + isActive: true, + lastUsedAt: true, + createdAt: true, + updatedAt: true, + }, + }) + + return NextResponse.json( + { + apiKey: rawKey, + key: created, + }, + { status: 201 }, + ) + } catch (error) { + logger.error({ err: error }, 'POST /api/routes-d/api-keys error') + return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) + } +} diff --git a/app/api/routes-d/kyc/source-of-funds/route.ts b/app/api/routes-d/kyc/source-of-funds/route.ts new file mode 100644 index 00000000..7e1bf2e3 --- /dev/null +++ b/app/api/routes-d/kyc/source-of-funds/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/lib/db' +import { logger } from '../../_shared/logger' +import { getAuthenticatedUser } from '../../_shared/auth' + +const SOURCE_TYPES = [ + 'salary', + 'business_income', + 'investments', + 'savings', + 'inheritance', + 'gift', + 'other', +] as const + +const SourceOfFundsSchema = z.object({ + sourceType: z.enum(SOURCE_TYPES), + details: z.string().trim().min(10).max(1000), + monthlyVolumeUsdc: z.number().positive().max(1_000_000).optional(), + annualIncomeUsdc: z.number().positive().max(10_000_000).optional(), +}) + +type SourceOfFundsDelegate = { + upsert: (args: Record) => Promise> +} + +function getDeclarationDelegate(): SourceOfFundsDelegate { + return (prisma as unknown as { sourceOfFundsDeclaration: SourceOfFundsDelegate }).sourceOfFundsDeclaration +} + +export async function POST(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + body = {} + } + + const parsed = SourceOfFundsSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.format() }, + { status: 400 }, + ) + } + + const declaration = await getDeclarationDelegate().upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + sourceType: parsed.data.sourceType, + details: parsed.data.details, + monthlyVolumeUsdc: parsed.data.monthlyVolumeUsdc ?? null, + annualIncomeUsdc: parsed.data.annualIncomeUsdc ?? null, + }, + update: { + sourceType: parsed.data.sourceType, + details: parsed.data.details, + monthlyVolumeUsdc: parsed.data.monthlyVolumeUsdc ?? null, + annualIncomeUsdc: parsed.data.annualIncomeUsdc ?? null, + }, + select: { + id: true, + sourceType: true, + details: true, + monthlyVolumeUsdc: true, + annualIncomeUsdc: true, + createdAt: true, + updatedAt: true, + }, + }) + + return NextResponse.json({ declaration }, { status: 201 }) + } catch (error) { + logger.error({ err: error }, 'POST /api/routes-d/kyc/source-of-funds error') + return NextResponse.json({ error: 'Failed to submit source-of-funds declaration' }, { status: 500 }) + } +} diff --git a/app/api/routes-d/reconciliation/match/route.ts b/app/api/routes-d/reconciliation/match/route.ts new file mode 100644 index 00000000..47f4939f --- /dev/null +++ b/app/api/routes-d/reconciliation/match/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { prisma } from '@/lib/db' +import { logger } from '../../_shared/logger' +import { getAuthenticatedUser } from '../../_shared/auth' + +const MatchSchema = z.object({ + transactionId: z.string().trim().min(1), + invoiceId: z.string().trim().min(1), +}) + +type TransactionRecord = { + id: string + userId: string + invoiceId: string | null + status: string + completedAt: Date | null +} + +type InvoiceRecord = { + id: string + userId: string + transaction: { id: string } | null + status: string + paidAt: Date | null +} + +type TransactionDelegate = { + findUnique: (args: Record) => Promise + update: (args: Record) => Promise> +} + +type InvoiceDelegate = { + findUnique: (args: Record) => Promise + update: (args: Record) => Promise> +} + +function getTransactionDelegate(): TransactionDelegate { + return (prisma as unknown as { transaction: TransactionDelegate }).transaction +} + +function getInvoiceDelegate(): InvoiceDelegate { + return (prisma as unknown as { invoice: InvoiceDelegate }).invoice +} + +export async function POST(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + body = {} + } + + const parsed = MatchSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.format() }, + { status: 400 }, + ) + } + + const { transactionId, invoiceId } = parsed.data + const transaction = await getTransactionDelegate().findUnique({ + where: { id: transactionId }, + select: { + id: true, + userId: true, + invoiceId: true, + status: true, + completedAt: true, + }, + }) + if (!transaction || transaction.userId !== user.id) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + const invoice = await getInvoiceDelegate().findUnique({ + where: { id: invoiceId }, + select: { + id: true, + userId: true, + transaction: { select: { id: true } }, + status: true, + paidAt: true, + }, + }) + if (!invoice || invoice.userId !== user.id) { + return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) + } + + if (transaction.invoiceId && transaction.invoiceId !== invoice.id) { + return NextResponse.json({ error: 'Transaction is already matched to another invoice' }, { status: 409 }) + } + if (invoice.transaction && invoice.transaction.id !== transaction.id) { + return NextResponse.json({ error: 'Invoice is already matched to another transaction' }, { status: 409 }) + } + + const matchedAt = new Date() + await prisma.$transaction(async (tx) => { + await tx.transaction.update({ + where: { id: transaction.id }, + data: { + invoiceId: invoice.id, + status: 'completed', + completedAt: transaction.completedAt ?? matchedAt, + }, + }) + + await tx.invoice.update({ + where: { id: invoice.id }, + data: { + status: 'paid', + paidAt: invoice.paidAt ?? matchedAt, + }, + }) + }) + + return NextResponse.json({ + match: { + transactionId: transaction.id, + invoiceId: invoice.id, + matchedAt: matchedAt.toISOString(), + }, + }) + } catch (error) { + logger.error({ err: error }, 'POST /api/routes-d/reconciliation/match error') + return NextResponse.json({ error: 'Failed to match transaction to invoice' }, { status: 500 }) + } +} diff --git a/app/api/routes-d/webhooks/event-catalog/route.ts b/app/api/routes-d/webhooks/event-catalog/route.ts new file mode 100644 index 00000000..2bdca36a --- /dev/null +++ b/app/api/routes-d/webhooks/event-catalog/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import { logger } from '../../_shared/logger' +import { getAuthenticatedUser } from '../../_shared/auth' +import { WEBHOOK_EVENT_CATALOG } from '../../_shared/webhook-events' + +export async function GET(request: NextRequest) { + try { + const user = await getAuthenticatedUser(request) + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + return NextResponse.json({ + eventTypes: WEBHOOK_EVENT_CATALOG, + count: WEBHOOK_EVENT_CATALOG.length, + }) + } catch (error) { + logger.error({ err: error }, 'GET /api/routes-d/webhooks/event-catalog error') + return NextResponse.json({ error: 'Failed to list webhook events' }, { status: 500 }) + } +} diff --git a/prisma/migrations/20260624000000_add_source_of_funds_declaration/migration.sql b/prisma/migrations/20260624000000_add_source_of_funds_declaration/migration.sql new file mode 100644 index 00000000..7b3440ed --- /dev/null +++ b/prisma/migrations/20260624000000_add_source_of_funds_declaration/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "SourceOfFundsDeclaration" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sourceType" VARCHAR(50) NOT NULL, + "details" TEXT NOT NULL, + "monthlyVolumeUsdc" DECIMAL(18,6), + "annualIncomeUsdc" DECIMAL(18,6), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SourceOfFundsDeclaration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SourceOfFundsDeclaration_userId_key" ON "SourceOfFundsDeclaration"("userId"); + +-- CreateIndex +CREATE INDEX "SourceOfFundsDeclaration_sourceType_idx" ON "SourceOfFundsDeclaration"("sourceType"); + +-- AddForeignKey +ALTER TABLE "SourceOfFundsDeclaration" ADD CONSTRAINT "SourceOfFundsDeclaration_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62fdc8b9..2e72594b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,13 +61,14 @@ model User { tags Tag[] timeEntries TimeEntry[] sessions UserSession[] - sanctionsScreening SanctionsScreening? - deletionRequests AccountDeletionRequest[] - kycApplication KycApplication? - kycDocuments KycDocument[] - teamMemberships TeamMember[] - ownedTeamMembers TeamMember[] @relation("TeamOwner") - whitelistAddresses WhitelistAddress[] + sanctionsScreening SanctionsScreening? + deletionRequests AccountDeletionRequest[] + kycApplication KycApplication? + sourceOfFundsDeclaration SourceOfFundsDeclaration? + kycDocuments KycDocument[] + teamMemberships TeamMember[] + ownedTeamMembers TeamMember[] @relation("TeamOwner") + whitelistAddresses WhitelistAddress[] clientNotes ClientNote[] clientNoteClients ClientNote[] @relation("ClientNotes") savedFilters SavedFilter[] @@ -121,6 +122,21 @@ model KycDocument { @@index([documentType]) } +model SourceOfFundsDeclaration { + id String @id @default(uuid()) + userId String @unique + sourceType String @db.VarChar(50) + details String @db.Text + monthlyVolumeUsdc Decimal? @db.Decimal(18, 6) + annualIncomeUsdc Decimal? @db.Decimal(18, 6) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([sourceType]) +} + model Notification { id String @id @default(uuid()) userId String diff --git a/tests/api.routes-d.api-keys.test.ts b/tests/api.routes-d.api-keys.test.ts new file mode 100644 index 00000000..ec14a540 --- /dev/null +++ b/tests/api.routes-d.api-keys.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const verifyAuthToken = vi.fn() +const userFindUnique = vi.fn() +const apiKeyFindMany = vi.fn() +const apiKeyCreate = vi.fn() + +vi.mock('@/lib/auth', () => ({ verifyAuthToken })) +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: userFindUnique }, + apiKey: { findMany: apiKeyFindMany, create: apiKeyCreate }, + }, +})) +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn() } })) + +function makeRequest(method: 'GET' | 'POST', body?: unknown, token: string | null = 'valid-token') { + const headers = new Headers({ 'content-type': 'application/json' }) + if (token) headers.set('authorization', `Bearer ${token}`) + return new NextRequest('http://localhost/api/routes-d/api-keys', { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +describe('API keys route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when not authenticated', async () => { + verifyAuthToken.mockResolvedValue(null) + const { GET } = await import('@/app/api/routes-d/api-keys/route') + const res = await GET(makeRequest('GET', undefined, null)) + expect(res.status).toBe(401) + }) + + it('lists the caller API keys', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + apiKeyFindMany.mockResolvedValue([ + { id: 'key_1', name: 'Desktop', keyHint: 'abc123', isActive: true, lastUsedAt: null, createdAt: new Date(), updatedAt: new Date() }, + ]) + + const { GET } = await import('@/app/api/routes-d/api-keys/route') + const res = await GET(makeRequest('GET')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.apiKeys).toHaveLength(1) + expect(apiKeyFindMany).toHaveBeenCalledWith(expect.objectContaining({ where: { userId: 'user_1' } })) + }) + + it('rejects invalid create payloads', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + const { POST } = await import('@/app/api/routes-d/api-keys/route') + const res = await POST(makeRequest('POST', { name: '' })) + expect(res.status).toBe(400) + expect(apiKeyCreate).not.toHaveBeenCalled() + }) + + it('creates an API key and returns the raw secret once', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + apiKeyCreate.mockResolvedValue({ + id: 'key_1', + name: 'Desktop', + keyHint: 'abc123', + isActive: true, + lastUsedAt: null, + createdAt: new Date('2026-06-24T00:00:00Z'), + updatedAt: new Date('2026-06-24T00:00:00Z'), + }) + + const { POST } = await import('@/app/api/routes-d/api-keys/route') + const res = await POST(makeRequest('POST', { name: 'Desktop' })) + expect(res.status).toBe(201) + const body = await res.json() + expect(body.apiKey).toMatch(/^rk_/) + expect(body.key).toMatchObject({ id: 'key_1', name: 'Desktop', keyHint: 'abc123' }) + expect(apiKeyCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ userId: 'user_1', name: 'Desktop', isActive: true }), + })) + }) +}) diff --git a/tests/api.routes-d.kyc.source-of-funds.test.ts b/tests/api.routes-d.kyc.source-of-funds.test.ts new file mode 100644 index 00000000..aedf377f --- /dev/null +++ b/tests/api.routes-d.kyc.source-of-funds.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const verifyAuthToken = vi.fn() +const userFindUnique = vi.fn() +const sourceOfFundsUpsert = vi.fn() + +vi.mock('@/lib/auth', () => ({ verifyAuthToken })) +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: userFindUnique }, + sourceOfFundsDeclaration: { upsert: sourceOfFundsUpsert }, + }, +})) +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn() } })) + +function makeRequest(body?: unknown, token: string | null = 'valid-token') { + const headers = new Headers({ 'content-type': 'application/json' }) + if (token) headers.set('authorization', `Bearer ${token}`) + return new NextRequest('http://localhost/api/routes-d/kyc/source-of-funds', { + method: 'POST', + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +describe('POST /api/routes-d/kyc/source-of-funds', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when not authenticated', async () => { + verifyAuthToken.mockResolvedValue(null) + const { POST } = await import('@/app/api/routes-d/kyc/source-of-funds/route') + const res = await POST(makeRequest({ sourceType: 'salary', details: 'Income from employment' }, null)) + expect(res.status).toBe(401) + }) + + it('rejects invalid payloads', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + const { POST } = await import('@/app/api/routes-d/kyc/source-of-funds/route') + const res = await POST(makeRequest({ sourceType: 'unknown', details: 'short' })) + expect(res.status).toBe(400) + expect(sourceOfFundsUpsert).not.toHaveBeenCalled() + }) + + it('stores a source-of-funds declaration for the authenticated user', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + sourceOfFundsUpsert.mockResolvedValue({ + id: 'sof_1', + sourceType: 'business_income', + details: 'Revenue from freelance consulting and product work', + monthlyVolumeUsdc: '5000.00', + annualIncomeUsdc: '60000.00', + createdAt: new Date('2026-06-24T00:00:00Z'), + updatedAt: new Date('2026-06-24T00:00:00Z'), + }) + + const { POST } = await import('@/app/api/routes-d/kyc/source-of-funds/route') + const res = await POST( + makeRequest({ + sourceType: 'business_income', + details: 'Revenue from freelance consulting and product work', + monthlyVolumeUsdc: 5000, + annualIncomeUsdc: 60000, + }), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.declaration).toMatchObject({ id: 'sof_1', sourceType: 'business_income' }) + expect(sourceOfFundsUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user_1' }, + create: expect.objectContaining({ userId: 'user_1', sourceType: 'business_income' }), + }), + ) + }) +}) diff --git a/tests/api.routes-d.reconciliation.match.test.ts b/tests/api.routes-d.reconciliation.match.test.ts new file mode 100644 index 00000000..abd5e8a7 --- /dev/null +++ b/tests/api.routes-d.reconciliation.match.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const verifyAuthToken = vi.fn() +const userFindUnique = vi.fn() +const transactionFindUnique = vi.fn() +const transactionUpdate = vi.fn() +const invoiceFindUnique = vi.fn() +const invoiceUpdate = vi.fn() +const transactionCallback = vi.fn() + +vi.mock('@/lib/auth', () => ({ verifyAuthToken })) +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: userFindUnique }, + transaction: { findUnique: transactionFindUnique, update: transactionUpdate }, + invoice: { findUnique: invoiceFindUnique, update: invoiceUpdate }, + $transaction: transactionCallback, + }, +})) +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn() } })) + +function makeRequest(body?: unknown, token: string | null = 'valid-token') { + const headers = new Headers({ 'content-type': 'application/json' }) + if (token) headers.set('authorization', `Bearer ${token}`) + return new NextRequest('http://localhost/api/routes-d/reconciliation/match', { + method: 'POST', + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +describe('POST /api/routes-d/reconciliation/match', () => { + beforeEach(() => { + vi.clearAllMocks() + transactionCallback.mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ + transaction: { update: transactionUpdate }, + invoice: { update: invoiceUpdate }, + }), + ) + }) + + it('returns 401 when not authenticated', async () => { + verifyAuthToken.mockResolvedValue(null) + const { POST } = await import('@/app/api/routes-d/reconciliation/match/route') + const res = await POST(makeRequest({ transactionId: 'tx_1', invoiceId: 'inv_1' }, null)) + expect(res.status).toBe(401) + }) + + it('returns 404 when the transaction is not owned by the caller', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + transactionFindUnique.mockResolvedValue({ id: 'tx_1', userId: 'user_other', invoiceId: null, status: 'completed', completedAt: null }) + + const { POST } = await import('@/app/api/routes-d/reconciliation/match/route') + const res = await POST(makeRequest({ transactionId: 'tx_1', invoiceId: 'inv_1' })) + expect(res.status).toBe(404) + }) + + it('returns 409 when the transaction is already matched to another invoice', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + transactionFindUnique.mockResolvedValue({ id: 'tx_1', userId: 'user_1', invoiceId: 'inv_other', status: 'completed', completedAt: null }) + invoiceFindUnique.mockResolvedValue({ id: 'inv_1', userId: 'user_1', transaction: null, status: 'pending', paidAt: null }) + + const { POST } = await import('@/app/api/routes-d/reconciliation/match/route') + const res = await POST(makeRequest({ transactionId: 'tx_1', invoiceId: 'inv_1' })) + expect(res.status).toBe(409) + }) + + it('matches the transaction to the invoice', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + transactionFindUnique.mockResolvedValue({ id: 'tx_1', userId: 'user_1', invoiceId: null, status: 'completed', completedAt: null }) + invoiceFindUnique.mockResolvedValue({ id: 'inv_1', userId: 'user_1', transaction: null, status: 'pending', paidAt: null }) + transactionUpdate.mockResolvedValue({ id: 'tx_1' }) + invoiceUpdate.mockResolvedValue({ id: 'inv_1' }) + + const { POST } = await import('@/app/api/routes-d/reconciliation/match/route') + const res = await POST(makeRequest({ transactionId: 'tx_1', invoiceId: 'inv_1' })) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.match).toMatchObject({ transactionId: 'tx_1', invoiceId: 'inv_1' }) + expect(transactionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'tx_1' }, + data: expect.objectContaining({ invoiceId: 'inv_1', status: 'completed' }), + }), + ) + expect(invoiceUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'inv_1' }, + data: expect.objectContaining({ status: 'paid' }), + }), + ) + }) +}) diff --git a/tests/api.routes-d.webhooks.event-catalog.test.ts b/tests/api.routes-d.webhooks.event-catalog.test.ts new file mode 100644 index 00000000..2ee94e83 --- /dev/null +++ b/tests/api.routes-d.webhooks.event-catalog.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextRequest } from 'next/server' + +const verifyAuthToken = vi.fn() +const userFindUnique = vi.fn() + +vi.mock('@/lib/auth', () => ({ verifyAuthToken })) +vi.mock('@/lib/db', () => ({ + prisma: { + user: { findUnique: userFindUnique }, + }, +})) +vi.mock('@/lib/logger', () => ({ + logger: { error: vi.fn() }, +})) + +function makeRequest(token: string | null = 'valid-token') { + const headers = new Headers() + if (token) headers.set('authorization', `Bearer ${token}`) + return new NextRequest('http://localhost/api/routes-d/webhooks/event-catalog', { headers }) +} + +describe('GET /api/routes-d/webhooks/event-catalog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when the user is not authenticated', async () => { + verifyAuthToken.mockResolvedValue(null) + const { GET } = await import('@/app/api/routes-d/webhooks/event-catalog/route') + const res = await GET(makeRequest()) + expect(res.status).toBe(401) + }) + + it('returns the available webhook event types', async () => { + verifyAuthToken.mockResolvedValue({ userId: 'privy_1' }) + userFindUnique.mockResolvedValue({ id: 'user_1', role: 'freelancer' }) + + const { GET } = await import('@/app/api/routes-d/webhooks/event-catalog/route') + const res = await GET(makeRequest()) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.count).toBe(body.eventTypes.length) + expect(body.eventTypes.map((event: { type: string }) => event.type)).toEqual( + expect.arrayContaining(['invoice.paid', 'kyc.submitted', 'reconciliation.matched']), + ) + }) +})