Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/api/routes-d/_shared/auth.ts
Original file line number Diff line number Diff line change
@@ -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<RoutesDUser | null> {
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 },
})
}
21 changes: 21 additions & 0 deletions app/api/routes-d/_shared/webhook-events.ts
Original file line number Diff line number Diff line change
@@ -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.' },
]
103 changes: 103 additions & 0 deletions app/api/routes-d/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<Array<Record<string, unknown>>>
create: (args: Record<string, unknown>) => Promise<Record<string, unknown>>
}

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 })
}
}
85 changes: 85 additions & 0 deletions app/api/routes-d/kyc/source-of-funds/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<Record<string, unknown>>
}

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 })
}
}
135 changes: 135 additions & 0 deletions app/api/routes-d/reconciliation/match/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<TransactionRecord | null>
update: (args: Record<string, unknown>) => Promise<Record<string, unknown>>
}

type InvoiceDelegate = {
findUnique: (args: Record<string, unknown>) => Promise<InvoiceRecord | null>
update: (args: Record<string, unknown>) => Promise<Record<string, unknown>>
}

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 })
}
}
21 changes: 21 additions & 0 deletions app/api/routes-d/webhooks/event-catalog/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Loading
Loading