we-superapp / apps /api /src /modules /finance.js
nenbuj
Initial deploy: «МЫ» супер-апп
c562e21
Raw History Blame Contribute Delete
4.9 kB
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 };
});
}