Skip to content
Open
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
239 changes: 126 additions & 113 deletions src/pages/api/sponsor-dashboard/grants/add-tranche.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextApiResponse } from 'next';

import logger from '@/lib/logger';
import { LockNotAcquiredError, withRedisLock } from '@/lib/with-redis-lock';
import { prisma } from '@/prisma';
import { getTokenBySymbol } from '@/server/tokenList';
import { safeStringify } from '@/utils/safeStringify';
Expand Down Expand Up @@ -39,122 +40,134 @@ async function handler(req: NextApiRequestWithSponsor, res: NextApiResponse) {
}

try {
logger.info(`Fetching grant application with ID: ${id}`);
const currentApplication = await prisma.grantApplication.findUnique({
where: { id },
include: { grant: true },
});

if (!currentApplication) {
logger.info(`Grant application not found with ID: ${id}`);
return res.status(404).json({ error: 'Grant application not found' });
}

const { error } = await checkGrantSponsorAuth(
userSponsorId,
currentApplication.grantId,
return await withRedisLock(
`locks:add-tranche:${id}`,
async () => {
logger.info(`Fetching grant application with ID: ${id}`);
const currentApplication = await prisma.grantApplication.findUnique({
where: { id },
include: { grant: true },
});

if (!currentApplication) {
logger.info(`Grant application not found with ID: ${id}`);
return res.status(404).json({ error: 'Grant application not found' });
}

const { error } = await checkGrantSponsorAuth(
userSponsorId,
currentApplication.grantId,
);

if (error) {
return res.status(error.status).json({ error: error.message });
}

const remainingAmount =
currentApplication.approvedAmount - currentApplication.totalPaid;
if (parsedTrancheAmount > remainingAmount) {
return res.status(400).json({
error: `Tranche amount exceeds remaining approved amount (${remainingAmount})`,
});
}

const normalizedTxId = normalizePaymentTxId(txId);
const alreadyUsedTxIds = await findUsedPaymentTxIds([normalizedTxId]);
if (alreadyUsedTxIds.length > 0) {
return res.status(400).json({
error: `Transaction IDs already used: ${alreadyUsedTxIds.join(', ')}`,
});
}

const dbToken = await getTokenBySymbol(currentApplication.grant.token);
if (!dbToken) {
return res.status(400).json({
error: "Token doesn't exist for this grant",
});
}

let tokenPriceUSD: number | undefined;
try {
tokenPriceUSD = await fetchTokenUSDValue(dbToken.mintAddress);
} catch (err) {
logger.warn(
`Failed to fetch token price for ${dbToken.tokenSymbol}, falling back to fixed tolerance`,
);
}

const validationResult = await validatePayment({
txId: normalizedTxId,
recipientPublicKey: currentApplication.walletAddress,
expectedAmount: parsedTrancheAmount,
tokenMint: dbToken,
tokenPriceUSD,
});

if (!validationResult.isValid) {
return res.status(400).json({
error: validationResult.error,
message: `Transaction validation failed: ${validationResult.error}`,
});
}

let updatedPaymentDetails = currentApplication.paymentDetails || [];
if (!Array.isArray(updatedPaymentDetails)) {
updatedPaymentDetails = [];
}

updatedPaymentDetails.push({
txId: txId || null,
tranche: currentApplication.totalTranches + 1,
amount: parsedTrancheAmount,
});

const newTotalPaid =
currentApplication.totalPaid + parsedTrancheAmount;
const isFullyPaid = newTotalPaid >= currentApplication.approvedAmount;

logger.info('Updating payment details and grant information');
const result = await prisma.$transaction(async (tx) => {
const updatedGrantApplication = await tx.grantApplication.update({
where: { id },
data: {
totalPaid: {
increment: parsedTrancheAmount,
},
totalTranches: {
increment: 1,
},
paymentDetails: updatedPaymentDetails as any,
...(isFullyPaid && { applicationStatus: 'Completed' }),
},
include: {
user: true,
grant: true,
},
});

return updatedGrantApplication;
});

await queueEmail({
type: 'grantPaymentReceived',
id,
triggeredBy: userId,
userId: currentApplication.userId,
});

logger.info(
`Payment details updated successfully for grant application ID: ${id}`,
);
return res.status(200).json(result);
},
{ ttlSeconds: 300 },
);

if (error) {
return res.status(error.status).json({ error: error.message });
}

const remainingAmount =
currentApplication.approvedAmount - currentApplication.totalPaid;
if (parsedTrancheAmount > remainingAmount) {
return res.status(400).json({
error: `Tranche amount exceeds remaining approved amount (${remainingAmount})`,
});
}

const normalizedTxId = normalizePaymentTxId(txId);
const alreadyUsedTxIds = await findUsedPaymentTxIds([normalizedTxId]);
if (alreadyUsedTxIds.length > 0) {
return res.status(400).json({
error: `Transaction IDs already used: ${alreadyUsedTxIds.join(', ')}`,
});
}

const dbToken = await getTokenBySymbol(currentApplication.grant.token);
if (!dbToken) {
return res.status(400).json({
error: "Token doesn't exist for this grant",
});
}

let tokenPriceUSD: number | undefined;
try {
tokenPriceUSD = await fetchTokenUSDValue(dbToken.mintAddress);
} catch (err) {
logger.warn(
`Failed to fetch token price for ${dbToken.tokenSymbol}, falling back to fixed tolerance`,
);
}

const validationResult = await validatePayment({
txId: normalizedTxId,
recipientPublicKey: currentApplication.walletAddress,
expectedAmount: parsedTrancheAmount,
tokenMint: dbToken,
tokenPriceUSD,
});

if (!validationResult.isValid) {
return res.status(400).json({
error: validationResult.error,
message: `Transaction validation failed: ${validationResult.error}`,
} catch (error: any) {
if (error instanceof LockNotAcquiredError) {
return res.status(409).json({
error: 'Payment processing already in progress for this application',
});
}

let updatedPaymentDetails = currentApplication.paymentDetails || [];
if (!Array.isArray(updatedPaymentDetails)) {
updatedPaymentDetails = [];
}

updatedPaymentDetails.push({
txId: txId || null,
tranche: currentApplication.totalTranches + 1,
amount: parsedTrancheAmount,
});

const newTotalPaid = currentApplication.totalPaid + parsedTrancheAmount;
const isFullyPaid = newTotalPaid >= currentApplication.approvedAmount;

logger.info('Updating payment details and grant information');
const result = await prisma.$transaction(async (tx) => {
const updatedGrantApplication = await tx.grantApplication.update({
where: { id },
data: {
totalPaid: {
increment: parsedTrancheAmount,
},
totalTranches: {
increment: 1,
},
paymentDetails: updatedPaymentDetails as any,
...(isFullyPaid && { applicationStatus: 'Completed' }),
},
include: {
user: true,
grant: true,
},
});

return updatedGrantApplication;
});

await queueEmail({
type: 'grantPaymentReceived',
id,
triggeredBy: userId,
userId: currentApplication.userId,
});

logger.info(
`Payment details updated successfully for grant application ID: ${id}`,
);
return res.status(200).json(result);
} catch (error: any) {
logger.error(
`Error occurred while updating payment for grant application ID: ${id}`,
safeStringify(error),
Expand Down
Loading