Spaces:
Paused
Paused
Download apps/api/src/modules/finance.js from nenbuj/we-superapp: direct link, hf CLI and curl.
- Browser
- Download file 4.9 kB
-
https://huggingface.co/spaces/nenbuj/we-superapp/resolve/main/apps/api/src/modules/finance.js
- Command line
-
hf download hf://spaces/nenbuj/we-superapp/apps/api/src/modules/finance.js
-
curl -L -o finance.js https://huggingface.co/spaces/nenbuj/we-superapp/resolve/main/apps/api/src/modules/finance.js
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 }; | |
| }); | |
| } | |