Spaces:
Paused
Paused
File size: 4,903 Bytes
c562e21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | 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 };
});
}
|