forked from Nova-reward/Nova-Rewards
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpointTransactionRepository.js
More file actions
161 lines (144 loc) · 4.56 KB
/
Copy pathpointTransactionRepository.js
File metadata and controls
161 lines (144 loc) · 4.56 KB
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
160
161
const { query } = require('./index');
/**
* Records a point transaction with balance tracking.
*
* Fetches the current balance inside a transaction, computes balance_before /
* balance_after, enforces the non-negative balance invariant, then inserts the
* row. The DB trigger on point_transactions keeps user_balance in sync
* automatically.
*
* @param {object} params
* @param {number} params.userId
* @param {string} params.type - 'earned' | 'redeemed' | 'expired' | 'bonus' | 'referral'
* @param {number} params.amount - Positive integer (sign is derived from type)
* @param {string} [params.description]
* @param {number} [params.referredUserId]
* @param {number} [params.campaignId]
* @returns {Promise<object>} The inserted transaction row
*/
async function recordPointTransaction({
userId,
type,
amount,
description,
referredUserId,
campaignId,
}) {
const DEBIT_TYPES = new Set(['redeemed', 'expired']);
const intAmount = Math.round(Number(amount));
if (!Number.isInteger(intAmount) || intAmount === 0) {
throw Object.assign(new Error('amount must be a non-zero integer'), { status: 400 });
}
const client = await require('./index').pool.connect();
try {
await client.query('BEGIN');
// Lock the user_balance row (or create it) to prevent race conditions
await client.query(
`INSERT INTO user_balance (user_id, balance) VALUES ($1, 0)
ON CONFLICT (user_id) DO NOTHING`,
[userId]
);
const { rows: balRows } = await client.query(
'SELECT balance FROM user_balance WHERE user_id = $1 FOR UPDATE',
[userId]
);
const balanceBefore = balRows[0]?.balance ?? 0;
const delta = DEBIT_TYPES.has(type) ? -intAmount : intAmount;
const balanceAfter = balanceBefore + delta;
if (balanceAfter < 0) {
throw Object.assign(
new Error(`Insufficient balance: current ${balanceBefore}, attempted debit ${intAmount}`),
{ status: 422 }
);
}
const { rows } = await client.query(
`INSERT INTO point_transactions
(user_id, type, amount, balance_before, balance_after, description, referred_user_id, campaign_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[userId, type, intAmount, balanceBefore, balanceAfter, description ?? null, referredUserId ?? null, campaignId ?? null]
);
await client.query('COMMIT');
return rows[0];
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
/**
* Gets paginated point transactions for a user.
*
* @param {number} userId
* @param {{ page?: number, limit?: number }} opts
* @returns {Promise<{ data: object[], total: number, page: number, limit: number }>}
*/
async function getUserPointTransactions(userId, { page = 1, limit = 20 } = {}) {
const offset = (page - 1) * limit;
const { rows: countRows } = await query(
'SELECT COUNT(*) AS total FROM point_transactions WHERE user_id = $1',
[userId]
);
const total = parseInt(countRows[0].total, 10);
const { rows } = await query(
`SELECT pt.*, u.wallet_address AS referred_user_wallet
FROM point_transactions pt
LEFT JOIN users u ON pt.referred_user_id = u.id
WHERE pt.user_id = $1
ORDER BY pt.created_at DESC
LIMIT $2 OFFSET $3`,
[userId, limit, offset]
);
return { data: rows, total, page, limit };
}
/**
* Returns the current balance for a user from the user_balance table.
*
* @param {number} userId
* @returns {Promise<number>}
*/
async function getUserBalance(userId) {
const { rows } = await query(
'SELECT balance FROM user_balance WHERE user_id = $1',
[userId]
);
return rows[0]?.balance ?? 0;
}
/**
* Gets total points earned by a user (earned + referral + bonus).
*
* @param {number} userId
* @returns {Promise<string>}
*/
async function getUserTotalPoints(userId) {
const { rows } = await query(
`SELECT COALESCE(SUM(amount), 0) AS total
FROM point_transactions
WHERE user_id = $1 AND type IN ('earned', 'referral', 'bonus')`,
[userId]
);
return String(rows[0].total);
}
/**
* Gets total referral points earned by a user.
*
* @param {number} userId
* @returns {Promise<string>}
*/
async function getUserReferralPoints(userId) {
const { rows } = await query(
`SELECT COALESCE(SUM(amount), 0) AS total
FROM point_transactions
WHERE user_id = $1 AND type = 'referral'`,
[userId]
);
return String(rows[0].total);
}
module.exports = {
recordPointTransaction,
getUserPointTransactions,
getUserBalance,
getUserTotalPoints,
getUserReferralPoints,
};