feat: add investment and redemption functionality for hedge funds
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
- Implemented POST endpoint for investing in hedge funds, allowing users to invest a specified amount and receive shares based on the fund's NAV. - Implemented POST endpoint for redeeming shares in hedge funds, enabling users to redeem shares for cash based on the current NAV. - Created InvestPanel component for user interactions, including investing and redeeming shares, displaying current NAV, user balance, and holdings summary.
This commit is contained in:
+25
-7
@@ -26,21 +26,39 @@ model User {
|
||||
passwordResets PasswordReset[]
|
||||
managedFunds FundManager[]
|
||||
fund HedgeFund?
|
||||
fundInvestments FundInvestment[]
|
||||
}
|
||||
|
||||
model HedgeFund {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
slug String @unique // lowercase, URL-safe
|
||||
userId String @unique // shadow User account that holds positions/trades/balance
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
slug String @unique // lowercase, URL-safe
|
||||
userId String @unique // shadow User account that holds positions/trades/balance
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
sharesOutstanding Float @default(0) // total fund shares currently in circulation
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
managers FundManager[]
|
||||
managers FundManager[]
|
||||
investments FundInvestment[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model FundInvestment {
|
||||
id String @id @default(cuid())
|
||||
fundId String
|
||||
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shares Float // fund shares held by this investor
|
||||
avgNavAtBuy Float // NAV per share at time of purchase (for display only)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([fundId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model FundManager {
|
||||
id String @id @default(cuid())
|
||||
fundId String
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { calcFundNav } from '@/lib/pricing'
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||
const body = await req.json()
|
||||
const amount = Number(body.amount)
|
||||
|
||||
if (!amount || amount < 1) {
|
||||
return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fund = await prisma.hedgeFund.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
balance: true,
|
||||
positions: {
|
||||
where: { shares: { gt: 0 } },
|
||||
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!fund) return NextResponse.json({ error: 'Fund not found' }, { status: 404 })
|
||||
|
||||
// Verify investor is not the fund's own shadow user
|
||||
const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true, isFund: true } })
|
||||
if (!investor || investor.isFund) {
|
||||
return NextResponse.json({ error: 'Invalid account' }, { status: 403 })
|
||||
}
|
||||
if (investor.balance < amount) {
|
||||
return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
|
||||
}
|
||||
|
||||
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||
const val = p.positionType === 'LONG'
|
||||
? p.shares * p.hashtag.currentPrice
|
||||
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||
return sum + val
|
||||
}, 0)
|
||||
const totalValue = fund.user.balance + portfolioValue
|
||||
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||
const sharesToMint = amount / nav
|
||||
|
||||
// Weighted average NAV at buy for display
|
||||
const existingInvestment = await prisma.fundInvestment.findUnique({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||
select: { shares: true, avgNavAtBuy: true },
|
||||
})
|
||||
|
||||
let newAvgNav: number
|
||||
if (existingInvestment) {
|
||||
const totalShares = existingInvestment.shares + sharesToMint
|
||||
newAvgNav = (existingInvestment.avgNavAtBuy * existingInvestment.shares + nav * sharesToMint) / totalShares
|
||||
} else {
|
||||
newAvgNav = nav
|
||||
}
|
||||
|
||||
const [updatedInvestor] = await prisma.$transaction([
|
||||
// Deduct from investor (returns updated user with new balance)
|
||||
prisma.user.update({ where: { id: session.user.id }, data: { balance: { decrement: amount } } }),
|
||||
// Add to fund's cash
|
||||
prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }),
|
||||
// Upsert FundInvestment record
|
||||
prisma.fundInvestment.upsert({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||
create: { fundId: fund.id, userId: session.user.id, shares: sharesToMint, avgNavAtBuy: newAvgNav },
|
||||
update: { shares: { increment: sharesToMint }, avgNavAtBuy: newAvgNav },
|
||||
}),
|
||||
// Increment fund shares outstanding
|
||||
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
shares: sharesToMint,
|
||||
nav,
|
||||
newBalance: updatedInvestor.balance,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { calcFundNav } from '@/lib/pricing'
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||
const body = await req.json()
|
||||
const sharesToRedeem = Number(body.shares)
|
||||
|
||||
if (!sharesToRedeem || sharesToRedeem <= 0) {
|
||||
return NextResponse.json({ error: 'Invalid share amount' }, { status: 400 })
|
||||
}
|
||||
|
||||
const fund = await prisma.hedgeFund.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
balance: true,
|
||||
positions: {
|
||||
where: { shares: { gt: 0 } },
|
||||
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!fund) return NextResponse.json({ error: 'Fund not found' }, { status: 404 })
|
||||
|
||||
const investment = await prisma.fundInvestment.findUnique({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||
select: { shares: true },
|
||||
})
|
||||
|
||||
if (!investment || investment.shares < sharesToRedeem) {
|
||||
return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 })
|
||||
}
|
||||
|
||||
const portfolioValue = fund.user.positions.reduce((sum, p) => {
|
||||
const val = p.positionType === 'LONG'
|
||||
? p.shares * p.hashtag.currentPrice
|
||||
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||
return sum + val
|
||||
}, 0)
|
||||
const totalValue = fund.user.balance + portfolioValue
|
||||
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||
const payout = sharesToRedeem * nav
|
||||
|
||||
if (fund.user.balance < payout) {
|
||||
return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const remainingShares = investment.shares - sharesToRedeem
|
||||
|
||||
const [updatedInvestor] = await prisma.$transaction([
|
||||
// Return cash to investor
|
||||
prisma.user.update({ where: { id: session.user.id }, data: { balance: { increment: payout } } }),
|
||||
// Deduct from fund's cash
|
||||
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
|
||||
// Update or delete FundInvestment
|
||||
...(remainingShares > 0
|
||||
? [prisma.fundInvestment.update({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||
data: { shares: remainingShares },
|
||||
})]
|
||||
: [prisma.fundInvestment.delete({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||
})]),
|
||||
// Decrement fund shares outstanding
|
||||
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
payout,
|
||||
nav,
|
||||
newBalance: updatedInvestor.balance,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
fundSlug: string
|
||||
nav: number
|
||||
userBalance: number
|
||||
userShares: number
|
||||
userAvgNav: number
|
||||
}
|
||||
|
||||
export default function InvestPanel({ fundSlug, nav, userBalance, userShares, userAvgNav }: Props) {
|
||||
const router = useRouter()
|
||||
const [tab, setTab] = useState<'invest' | 'redeem'>('invest')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [shares, setShares] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const amountNum = parseFloat(amount) || 0
|
||||
const sharesNum = parseFloat(shares) || 0
|
||||
const previewShares = nav > 0 ? amountNum / nav : 0
|
||||
const previewPayout = sharesNum * nav
|
||||
|
||||
async function handleInvest(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
if (amountNum < 1) { setError('Minimum $1'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/funds/${fundSlug}/invest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: amountNum }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||
setSuccess(`Invested! You received ${data.shares.toFixed(4)} shares at NAV ${formatCurrency(data.nav)}.`)
|
||||
setAmount('')
|
||||
router.refresh()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
if (sharesNum <= 0) { setError('Enter shares to redeem'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/funds/${fundSlug}/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shares: sharesNum }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||
setSuccess(`Redeemed ${sharesNum.toFixed(4)} shares for ${formatCurrency(data.payout)}.`)
|
||||
setShares('')
|
||||
router.refresh()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-surface-border">
|
||||
{(['invest', 'redeem'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setError(''); setSuccess('') }}
|
||||
className={`flex-1 py-3 text-sm font-medium capitalize transition-colors ${
|
||||
tab === t
|
||||
? 'text-indigo-300 border-b-2 border-indigo-400 bg-indigo-500/5'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* NAV info */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-400">Current NAV / share</span>
|
||||
<span className="font-semibold">{formatCurrency(nav)}</span>
|
||||
</div>
|
||||
|
||||
{/* Holdings summary */}
|
||||
{userShares > 0 && (
|
||||
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-lg px-4 py-3 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Your shares</span>
|
||||
<span className="font-medium">{userShares.toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Current value</span>
|
||||
<span className="font-medium">{formatCurrency(userShares * nav)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Avg buy NAV</span>
|
||||
<span className="font-medium">{formatCurrency(userAvgNav)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'invest' ? (
|
||||
<form onSubmit={handleInvest} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 mb-1 block">Amount to invest</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-surface border border-surface-border rounded-lg pl-7 pr-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Balance: {formatCurrency(userBalance)}
|
||||
</p>
|
||||
</div>
|
||||
{amountNum >= 1 && (
|
||||
<p className="text-xs text-slate-400">
|
||||
You'll receive ≈ <span className="text-white font-medium">{previewShares.toFixed(4)} shares</span>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || amountNum < 1}
|
||||
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Investing…' : 'Invest'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleRedeem} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 mb-1 block">Shares to redeem</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
max={userShares}
|
||||
value={shares}
|
||||
onChange={(e) => setShares(e.target.value)}
|
||||
placeholder="0.0000"
|
||||
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
{userShares > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShares(String(userShares))}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 mt-1"
|
||||
>
|
||||
Max ({userShares.toFixed(4)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sharesNum > 0 && (
|
||||
<p className="text-xs text-slate-400">
|
||||
You'll receive ≈ <span className="text-white font-medium">{formatCurrency(previewPayout)}</span>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || sharesNum <= 0 || sharesNum > userShares}
|
||||
className="w-full py-2.5 rounded-lg bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Redeeming…' : 'Redeem'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
{success && <p className="text-emerald-400 text-sm">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import Link from 'next/link'
|
||||
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import { calcFundNav } from '@/lib/pricing'
|
||||
import InvestPanel from './InvestPanel'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -34,11 +36,23 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
||||
include: { user: { select: { id: true, username: true, displayUsername: true } } },
|
||||
orderBy: { addedAt: 'asc' },
|
||||
},
|
||||
_count: { select: { investments: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!fund) notFound()
|
||||
|
||||
// Fetch current user's balance and investment in this fund
|
||||
const [currentUser, userInvestment] = session
|
||||
? await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true, isFund: true } }),
|
||||
prisma.fundInvestment.findUnique({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
|
||||
select: { shares: true, avgNavAtBuy: true },
|
||||
}),
|
||||
])
|
||||
: [null, null]
|
||||
|
||||
const isManager = session
|
||||
? fund.managers.some((m) => m.userId === session.user.id)
|
||||
: false
|
||||
@@ -58,6 +72,8 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
||||
}, 0)
|
||||
|
||||
const totalValue = fund.user.balance + portfolioValue
|
||||
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
|
||||
const canInvest = !!session && !!currentUser && !currentUser.isFund
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
@@ -82,12 +98,14 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{[
|
||||
{ label: 'Cash', value: formatCurrency(fund.user.balance) },
|
||||
{ label: 'Invested', value: formatCurrency(portfolioValue) },
|
||||
{ label: 'Unrealized P&L', value: formatPnl(unrealizedPnl), colorClass: pnlColor(unrealizedPnl) },
|
||||
{ label: 'Managers', value: String(fund.managers.length) },
|
||||
{ label: 'NAV / Share', value: formatCurrency(nav) },
|
||||
{ label: 'Shares Out.', value: fund.sharesOutstanding.toFixed(2) },
|
||||
{ label: 'Investors', value: String(fund._count.investments) },
|
||||
].map(({ label, value, colorClass }) => (
|
||||
<div key={label} className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||
<p className="text-xs text-slate-500 mb-1">{label}</p>
|
||||
@@ -127,6 +145,22 @@ export default async function FundPage({ params }: { params: { slug: string } })
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invest / Redeem panel */}
|
||||
{canInvest && (
|
||||
<div>
|
||||
<h2 className="font-semibold mb-3 flex items-center gap-2 text-sm text-slate-400 uppercase tracking-wider">
|
||||
Invest in this Fund
|
||||
</h2>
|
||||
<InvestPanel
|
||||
fundSlug={fund.slug}
|
||||
nav={nav}
|
||||
userBalance={currentUser!.balance}
|
||||
userShares={userInvestment?.shares ?? 0}
|
||||
userAvgNav={userInvestment?.avgNavAtBuy ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open positions */}
|
||||
{positions.length > 0 && (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
|
||||
@@ -39,6 +39,12 @@ export function getBalanceTier(balance: number): BalanceTier {
|
||||
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
|
||||
}
|
||||
|
||||
/** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */
|
||||
export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
|
||||
if (sharesOutstanding <= 0) return 1.00
|
||||
return totalValue / sharesOutstanding
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cost/proceeds and realized P&L for a trade.
|
||||
*
|
||||
|
||||
@@ -11,5 +11,6 @@ export const config = {
|
||||
'/api/user/:path*',
|
||||
'/api/admin/:path*',
|
||||
'/api/lottery/:path*',
|
||||
'/api/funds/:path*',
|
||||
],
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user