import crypto from 'node:crypto'; import { db } from '../core/db.js'; import { bus } from '../core/bus.js'; import { requireMember } from '../core/guards.js'; function sendError(reply, status, code, message) { return reply.code(status).send({ error: { code, message } }); } function parseTransaction(row) { return { ...row, split: JSON.parse(row.split), }; } function validateSplit(split) { if (!split || typeof split !== 'object' || Array.isArray(split)) { return 'split должен быть объектом { user_id: процент }'; } const entries = Object.entries(split); if (entries.length === 0) { return 'split не может быть пустым'; } let sum = 0; for (const [userId, percent] of entries) { if (!userId || typeof userId !== 'string') { return 'split должен содержать строковые user_id'; } if (!Number.isFinite(percent) || percent < 0) { return 'доли split должны быть числами >= 0'; } sum += percent; } if (Math.abs(sum - 100) > 0.000001) { return 'сумма долей split должна быть 100'; } return null; } function memberIds(spaceId) { return new Set( db .prepare('SELECT user_id FROM space_members WHERE space_id = ?') .all(spaceId) .map((row) => row.user_id) ); } function sharesBySplit(amount, split) { const rawShares = Object.entries(split).map(([userId, percent]) => { const raw = (amount * percent) / 100; const cents = Math.floor(raw); return { userId, cents, fraction: raw - cents }; }); let remainder = amount - rawShares.reduce((sum, share) => sum + share.cents, 0); rawShares.sort((a, b) => b.fraction - a.fraction); for (const share of rawShares) { if (remainder <= 0) break; share.cents += 1; remainder -= 1; } return rawShares; } export default async function financeRoutes(fastify) { fastify.get('/spaces/:id/transactions', async (request) => { await requireMember(request, request.params.id); const transactions = db .prepare('SELECT * FROM transactions WHERE space_id = ? ORDER BY created_at DESC') .all(request.params.id) .map(parseTransaction); return { transactions }; }); fastify.post('/spaces/:id/transactions', async (request, reply) => { await requireMember(request, request.params.id); const spaceId = request.params.id; const { title, amount, paid_by, split } = request.body || {}; if (!title || typeof title !== 'string') { return sendError(reply, 400, 'VALIDATION', 'title обязателен'); } if (!Number.isInteger(amount) || amount <= 0) { return sendError(reply, 400, 'VALIDATION', 'amount должен быть целым числом копеек > 0'); } if (!paid_by || typeof paid_by !== 'string') { return sendError(reply, 400, 'VALIDATION', 'paid_by обязателен'); } const splitError = validateSplit(split); if (splitError) { return sendError(reply, 400, 'VALIDATION', splitError); } const members = memberIds(spaceId); if (!members.has(paid_by)) { return sendError(reply, 400, 'VALIDATION', 'paid_by должен быть участником пространства'); } for (const userId of Object.keys(split)) { if (!members.has(userId)) { return sendError(reply, 400, 'VALIDATION', 'split может содержать только участников пространства'); } } const tx = { id: crypto.randomUUID(), space_id: spaceId, author_id: request.user.id, title, amount, paid_by, split, created_at: Date.now(), }; db.prepare( `INSERT INTO transactions (id, space_id, author_id, title, amount, paid_by, split, created_at) VALUES (?,?,?,?,?,?,?,?)` ).run( tx.id, tx.space_id, tx.author_id, tx.title, tx.amount, tx.paid_by, JSON.stringify(tx.split), tx.created_at ); bus.emit(spaceId, { type: 'tx.created', space_id: spaceId, payload: tx }); return reply.code(201).send({ transaction: tx }); }); fastify.get('/spaces/:id/balance', async (request) => { await requireMember(request, request.params.id); const spaceId = request.params.id; const balances = Object.fromEntries([...memberIds(spaceId)].map((userId) => [userId, 0])); const transactions = db.prepare('SELECT * FROM transactions WHERE space_id = ?').all(spaceId); for (const tx of transactions) { balances[tx.paid_by] = (balances[tx.paid_by] || 0) + tx.amount; const split = JSON.parse(tx.split); for (const share of sharesBySplit(tx.amount, split)) { balances[share.userId] = (balances[share.userId] || 0) - share.cents; } } return { balances }; }); }