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 };
  });
}