feat: sending money / coins
This commit is contained in:
parent
4e58d20e84
commit
de0987a007
14 changed files with 2825 additions and 325 deletions
|
|
@ -1,8 +1,9 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { transaction, coin } from '$lib/server/db/schema';
|
||||
import { transaction, coin, user } from '$lib/server/db/schema';
|
||||
import { eq, desc, asc, and, or, ilike, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
|
||||
export async function GET({ request, url }) {
|
||||
const authSession = await auth.api.getSession({
|
||||
|
|
@ -18,25 +19,43 @@ export async function GET({ request, url }) {
|
|||
const typeFilter = url.searchParams.get('type') || 'all';
|
||||
const sortBy = url.searchParams.get('sortBy') || 'timestamp';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
||||
|
||||
let whereConditions = and(eq(transaction.userId, userId));
|
||||
// Validate page parameter
|
||||
const pageParam = url.searchParams.get('page') || '1';
|
||||
const page = parseInt(pageParam);
|
||||
if (isNaN(page) || page < 1) {
|
||||
throw error(400, 'Invalid page parameter');
|
||||
}
|
||||
|
||||
// Validate limit parameter
|
||||
const limitParam = url.searchParams.get('limit') || '20';
|
||||
const parsedLimit = parseInt(limitParam);
|
||||
const limit = isNaN(parsedLimit) ? 20 : Math.min(Math.max(parsedLimit, 1), 50); const recipientUser = alias(user, 'recipientUser');
|
||||
|
||||
const senderUser = alias(user, 'senderUser');
|
||||
|
||||
const conditions = [eq(transaction.userId, userId)];
|
||||
|
||||
if (searchQuery) {
|
||||
whereConditions = and(
|
||||
whereConditions,
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(coin.name, `%${searchQuery}%`),
|
||||
ilike(coin.symbol, `%${searchQuery}%`)
|
||||
)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
whereConditions = and(whereConditions, eq(transaction.type, typeFilter as 'BUY' | 'SELL'));
|
||||
const validTypes = ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT'] as const;
|
||||
if (validTypes.includes(typeFilter as any)) {
|
||||
conditions.push(eq(transaction.type, typeFilter as typeof validTypes[number]));
|
||||
} else {
|
||||
throw error(400, `Invalid type parameter. Allowed: ${validTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const whereConditions = conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||
|
||||
let sortColumn;
|
||||
switch (sortBy) {
|
||||
case 'totalBaseCurrencyAmount':
|
||||
|
|
@ -52,12 +71,10 @@ export async function GET({ request, url }) {
|
|||
sortColumn = transaction.timestamp;
|
||||
}
|
||||
|
||||
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn);
|
||||
|
||||
const [{ count }] = await db
|
||||
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn); const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(transaction)
|
||||
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
||||
.innerJoin(coin, eq(transaction.coinId, coin.id))
|
||||
.where(whereConditions);
|
||||
|
||||
const transactions = await db
|
||||
|
|
@ -68,26 +85,64 @@ export async function GET({ request, url }) {
|
|||
pricePerCoin: transaction.pricePerCoin,
|
||||
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||
timestamp: transaction.timestamp,
|
||||
recipientUserId: transaction.recipientUserId,
|
||||
senderUserId: transaction.senderUserId,
|
||||
coin: {
|
||||
id: coin.id,
|
||||
name: coin.name,
|
||||
symbol: coin.symbol,
|
||||
icon: coin.icon
|
||||
},
|
||||
recipientUser: {
|
||||
id: recipientUser.id,
|
||||
username: recipientUser.username
|
||||
},
|
||||
senderUser: {
|
||||
id: senderUser.id,
|
||||
username: senderUser.username
|
||||
}
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
||||
}).from(transaction)
|
||||
.innerJoin(coin, eq(transaction.coinId, coin.id))
|
||||
.leftJoin(recipientUser, eq(transaction.recipientUserId, recipientUser.id))
|
||||
.leftJoin(senderUser, eq(transaction.senderUserId, senderUser.id))
|
||||
.where(whereConditions)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit);
|
||||
|
||||
const formattedTransactions = transactions.map(tx => ({
|
||||
...tx,
|
||||
quantity: Number(tx.quantity),
|
||||
pricePerCoin: Number(tx.pricePerCoin),
|
||||
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount)
|
||||
}));
|
||||
const formattedTransactions = transactions.map(tx => {
|
||||
const isTransfer = tx.type.startsWith('TRANSFER_');
|
||||
const isIncoming = tx.type === 'TRANSFER_IN';
|
||||
const isCoinTransfer = isTransfer && Number(tx.quantity) > 0;
|
||||
|
||||
let actualSenderUsername = null;
|
||||
let actualRecipientUsername = null;
|
||||
|
||||
if (isTransfer) {
|
||||
actualSenderUsername = tx.senderUser?.username;
|
||||
actualRecipientUsername = tx.recipientUser?.username;
|
||||
}
|
||||
|
||||
return {
|
||||
...tx,
|
||||
quantity: Number(tx.quantity),
|
||||
pricePerCoin: Number(tx.pricePerCoin),
|
||||
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount),
|
||||
isTransfer,
|
||||
isIncoming,
|
||||
isCoinTransfer,
|
||||
recipient: actualRecipientUsername,
|
||||
sender: actualSenderUsername,
|
||||
transferInfo: isTransfer ? {
|
||||
isTransfer: true,
|
||||
isIncoming,
|
||||
isCoinTransfer,
|
||||
otherUser: isIncoming ?
|
||||
(tx.senderUser ? { id: tx.senderUser.id, username: actualSenderUsername } : null) :
|
||||
(tx.recipientUser ? { id: tx.recipientUser.id, username: actualRecipientUsername } : null)
|
||||
} : null
|
||||
};
|
||||
});
|
||||
|
||||
return json({
|
||||
transactions: formattedTransactions,
|
||||
|
|
|
|||
259
website/src/routes/api/transfer/+server.ts
Normal file
259
website/src/routes/api/transfer/+server.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
interface TransferRequest {
|
||||
recipientUsername: string;
|
||||
type: 'CASH' | 'COIN';
|
||||
amount: number;
|
||||
coinSymbol?: string;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
} try {
|
||||
const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json();
|
||||
|
||||
if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
|
||||
throw error(400, 'Invalid transfer parameters');
|
||||
}
|
||||
|
||||
if (amount > Number.MAX_SAFE_INTEGER) {
|
||||
throw error(400, 'Transfer amount too large');
|
||||
}
|
||||
|
||||
if (type === 'COIN' && !coinSymbol) {
|
||||
throw error(400, 'Coin symbol required for coin transfers');
|
||||
}
|
||||
|
||||
const senderId = Number(session.user.id);
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [senderData] = await tx
|
||||
.select({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
baseCurrencyBalance: user.baseCurrencyBalance
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, senderId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!senderData) {
|
||||
throw error(404, 'Sender not found');
|
||||
}
|
||||
|
||||
const [recipientData] = await tx
|
||||
.select({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
baseCurrencyBalance: user.baseCurrencyBalance
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.username, recipientUsername))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!recipientData) {
|
||||
throw error(404, 'Recipient not found');
|
||||
}
|
||||
|
||||
if (senderData.id === recipientData.id) {
|
||||
throw error(400, 'Cannot transfer to yourself');
|
||||
}
|
||||
|
||||
if (type === 'CASH') {
|
||||
const senderBalance = Number(senderData.baseCurrencyBalance);
|
||||
if (senderBalance < amount) {
|
||||
throw error(400, `Insufficient funds. You have $${senderBalance.toFixed(2)} but trying to send $${amount.toFixed(2)}`);
|
||||
}
|
||||
|
||||
const recipientBalance = Number(recipientData.baseCurrencyBalance);
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: (senderBalance - amount).toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, senderId));
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: (recipientBalance + amount).toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, recipientData.id));
|
||||
|
||||
await tx.insert(transaction).values({
|
||||
userId: senderId,
|
||||
coinId: 1,
|
||||
type: 'TRANSFER_OUT',
|
||||
quantity: '0',
|
||||
pricePerCoin: '1',
|
||||
totalBaseCurrencyAmount: amount.toString(),
|
||||
timestamp: new Date(),
|
||||
senderUserId: senderId,
|
||||
recipientUserId: recipientData.id
|
||||
});
|
||||
|
||||
await tx.insert(transaction).values({
|
||||
userId: recipientData.id,
|
||||
coinId: 1,
|
||||
type: 'TRANSFER_IN',
|
||||
quantity: '0',
|
||||
pricePerCoin: '1',
|
||||
totalBaseCurrencyAmount: amount.toString(),
|
||||
timestamp: new Date(),
|
||||
senderUserId: senderId,
|
||||
recipientUserId: recipientData.id
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
type: 'CASH',
|
||||
amount,
|
||||
recipient: recipientData.username,
|
||||
newBalance: senderBalance - amount
|
||||
});
|
||||
|
||||
} else {
|
||||
const normalizedSymbol = coinSymbol!.toUpperCase();
|
||||
|
||||
const [coinData] = await tx
|
||||
.select({ id: coin.id, symbol: coin.symbol, name: coin.name, currentPrice: coin.currentPrice })
|
||||
.from(coin)
|
||||
.where(eq(coin.symbol, normalizedSymbol))
|
||||
.limit(1);
|
||||
|
||||
if (!coinData) {
|
||||
throw error(404, 'Coin not found');
|
||||
}
|
||||
|
||||
const [senderHolding] = await tx
|
||||
.select({
|
||||
quantity: userPortfolio.quantity
|
||||
})
|
||||
.from(userPortfolio)
|
||||
.where(and(
|
||||
eq(userPortfolio.userId, senderId),
|
||||
eq(userPortfolio.coinId, coinData.id)
|
||||
))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!senderHolding || Number(senderHolding.quantity) < amount) {
|
||||
const availableAmount = senderHolding ? Number(senderHolding.quantity) : 0;
|
||||
throw error(400, `Insufficient ${coinData.symbol}. You have ${availableAmount.toFixed(6)} but trying to send ${amount.toFixed(6)}`);
|
||||
}
|
||||
|
||||
const [recipientHolding] = await tx
|
||||
.select({ quantity: userPortfolio.quantity })
|
||||
.from(userPortfolio)
|
||||
.where(and(
|
||||
eq(userPortfolio.userId, recipientData.id),
|
||||
eq(userPortfolio.coinId, coinData.id)
|
||||
))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const coinPrice = Number(coinData.currentPrice) || 0;
|
||||
const totalValue = amount * coinPrice;
|
||||
|
||||
const newSenderQuantity = Number(senderHolding.quantity) - amount;
|
||||
if (newSenderQuantity > 0.000001) {
|
||||
await tx
|
||||
.update(userPortfolio)
|
||||
.set({
|
||||
quantity: newSenderQuantity.toString(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(and(
|
||||
eq(userPortfolio.userId, senderId),
|
||||
eq(userPortfolio.coinId, coinData.id)
|
||||
));
|
||||
} else {
|
||||
await tx
|
||||
.delete(userPortfolio)
|
||||
.where(and(
|
||||
eq(userPortfolio.userId, senderId),
|
||||
eq(userPortfolio.coinId, coinData.id)
|
||||
));
|
||||
}
|
||||
|
||||
if (recipientHolding) {
|
||||
const newRecipientQuantity = Number(recipientHolding.quantity) + amount;
|
||||
await tx
|
||||
.update(userPortfolio)
|
||||
.set({
|
||||
quantity: newRecipientQuantity.toString(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(and(
|
||||
eq(userPortfolio.userId, recipientData.id),
|
||||
eq(userPortfolio.coinId, coinData.id)
|
||||
));
|
||||
} else {
|
||||
await tx
|
||||
.insert(userPortfolio)
|
||||
.values({
|
||||
userId: recipientData.id,
|
||||
coinId: coinData.id,
|
||||
quantity: amount.toString()
|
||||
});
|
||||
}
|
||||
|
||||
await tx.insert(transaction).values({
|
||||
userId: senderId,
|
||||
coinId: coinData.id,
|
||||
type: 'TRANSFER_OUT',
|
||||
quantity: amount.toString(),
|
||||
pricePerCoin: coinPrice.toString(),
|
||||
totalBaseCurrencyAmount: totalValue.toString(),
|
||||
timestamp: new Date(),
|
||||
senderUserId: senderId,
|
||||
recipientUserId: recipientData.id
|
||||
});
|
||||
|
||||
await tx.insert(transaction).values({
|
||||
userId: recipientData.id,
|
||||
coinId: coinData.id,
|
||||
type: 'TRANSFER_IN',
|
||||
quantity: amount.toString(),
|
||||
pricePerCoin: coinPrice.toString(),
|
||||
totalBaseCurrencyAmount: totalValue.toString(),
|
||||
timestamp: new Date(),
|
||||
senderUserId: senderId,
|
||||
recipientUserId: recipientData.id
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
type: 'COIN',
|
||||
amount,
|
||||
coinSymbol: coinData.symbol,
|
||||
coinName: coinData.name,
|
||||
recipient: recipientData.username,
|
||||
newQuantity: newSenderQuantity
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Transfer error:', e);
|
||||
if (e && typeof e === 'object' && 'status' in e) {
|
||||
throw e;
|
||||
}
|
||||
return json({ error: 'Transfer failed' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in a new issue