Skip to content

Commit 14c5e5b

Browse files
committed
feat: added refunds
1 parent 1014469 commit 14c5e5b

9 files changed

Lines changed: 374 additions & 8 deletions

File tree

api/settings.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const getSettingsToggleSchema = () => {
4141
'LOW_FUNDS_WARNING',
4242
'ERROR_REPORTS_ACTIVE',
4343
'LOW_STOCK_WARNING',
44+
'AUTO_REFUNDS_ACTIVE',
4445
];
4546
if (!isEBGOAuthEnabled()) settingKeys.unshift('REG_CODE_ACTIVE');
4647

@@ -98,6 +99,10 @@ const settingsLowStockSchema = Joi.object({
9899
LOW_STOCK_PERCENT: Joi.number().min(0).max(100).required(),
99100
});
100101

102+
const settingsRefundsSchema = Joi.object({
103+
AUTO_REFUNDS_MINUTES: Joi.number().integer().min(1).max(10080).required(),
104+
});
105+
101106
router.get('/logs', verifyRequest('app.admin.stats.read'), limiter(5), async (req, res) => {
102107
return res.json({ logs: getMemoryLogs() });
103108
});
@@ -173,6 +178,12 @@ router.put('/lowStock', verifyRequest('app.admin.settings.write'), limiter(10),
173178
res.status(200).json({ success: true });
174179
});
175180

181+
router.put('/refunds', verifyRequest('app.admin.settings.write'), limiter(10), async (req, res) => {
182+
const body = await settingsRefundsSchema.validateAsync(req.body);
183+
await updateSetting('AUTO_REFUNDS_MINUTES', body.AUTO_REFUNDS_MINUTES.toString());
184+
res.status(200).json({ success: true });
185+
});
186+
176187
router.get('/backup', verifyRequest('app.admin.backup.read'), limiter(5), async (req, res) => {
177188
const backups = getBackups();
178189
return res.json(backups);

api/transactions.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const { verifyRequest } = require('@middleware/verifyRequest');
22
const { limiter } = require('@middleware/limiter');
3-
const { getUserTransactionHistory, getAllTransactionHistory, getUserIdByUUID } = require('@lib/sqlite/users');
3+
const { getUserTransactionHistory, getAllTransactionHistory, getUserIdByUUID, refundTransaction } = require('@lib/sqlite/users');
44
const Joi = require('@lib/sanitizer');
55
const express = require('ultimate-express');
66
const router = new express.Router();
@@ -20,6 +20,10 @@ const userUUIDSchema = Joi.object({
2020
uuid: Joi.string().uuid().required(),
2121
});
2222

23+
const transactionIdSchema = Joi.object({
24+
id: Joi.number().integer().min(1).required(),
25+
});
26+
2327
router.get('/', verifyRequest('web.user.transactions.read'), limiter(4), async (req, res) => {
2428
const query = await paginationSchema.validateAsync(req.query);
2529
const transactions = await getUserTransactionHistory(req.user.user_data.id, query.limit, query.page, query.groupbyday);
@@ -45,6 +49,28 @@ router.get('/user/:uuid', verifyRequest('web.admin.transactions.read'), limiter(
4549
res.json({ transactions });
4650
});
4751

52+
router.post('/:id/refund', verifyRequest('web.user.transactions.write'), limiter(2), async (req, res) => {
53+
const params = await transactionIdSchema.validateAsync(req.params);
54+
const result = refundTransaction(params.id, req.user.user_data.id);
55+
56+
if (!result.success) {
57+
return res.status(result.status || 400).json({ error: result.error || 'Refund failed' });
58+
}
59+
60+
return res.json({ success: true, refundedAmount: result.refundedAmount });
61+
});
62+
63+
router.post('/admin/:id/refund', verifyRequest('web.admin.transactions.write'), limiter(2), async (req, res) => {
64+
const params = await transactionIdSchema.validateAsync(req.params);
65+
const result = refundTransaction(params.id, req.user.user_data.id, { force: true });
66+
67+
if (!result.success) {
68+
return res.status(result.status || 400).json({ error: result.error || 'Refund failed' });
69+
}
70+
71+
return res.json({ success: true, refundedAmount: result.refundedAmount });
72+
});
73+
4874
module.exports = {
4975
router: router,
5076
PluginName: PluginName,

config/locales/de/AdminSettings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@
7676
"Percent": "Trigger in Prozent vom Zielbestand"
7777
}
7878
},
79+
"Refunds": {
80+
"Title": "Rückerstattungen",
81+
"Description": "Erlaube Benutzern, eigene Transaktionen innerhalb eines Zeitfensters automatisch rückerstatten zu lassen.",
82+
"ToggleLabel": "Automatische Rückerstattungen",
83+
"Minutes": "Zeitfenster in Minuten",
84+
"AUTO_REFUNDS_MINUTES": "Zeitfenster in Minuten"
85+
},
7986
"Button": {
8087
"Save": "Speichern",
8188
"Saving": "Speichern...",

config/locales/de/Items.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
"SystemPurchase": "Kauf von Artikel(n)",
1616
"AuthorizedBy": "Autorisiert von {{name}} um {{time}}",
1717
"GroupByDay": "Nach Tag gruppieren",
18+
"Refund": "Rückerstatten",
19+
"Refunded": "Rückerstattet",
20+
"RefundModalTitle": "Rückerstattung bestätigen",
21+
"RefundConfirm": "Möchtest du diese Transaktion wirklich rückerstatten?",
22+
"RefundSuccess": "Rückerstattung erfolgreich.",
23+
"RefundError": "Rückerstattung fehlgeschlagen.",
1824
"Sale": "Angebot",
1925
"Discounts": {
2026
"Manage": "Rabatte benachrichtigen",

lib/sqlite/users.js

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,14 @@ const getUserTransactionHistory = async (user_id, limit = 10, page = 1, groupbyd
261261
if (groupbyday) {
262262
query = `
263263
SELECT
264+
NULL AS id,
264265
t.user_id,
265266
t.item_id,
266267
SUM(t.quantity) AS quantity,
267268
SUM(t.price_at_transaction * t.quantity) / SUM(t.quantity) / 100.0 AS price_at_transaction,
268269
t.transaction_timestamp,
270+
t.status,
271+
0 AS can_refund,
269272
t.custom_item_text,
270273
i.uuid AS item_uuid,
271274
i.name AS item_name,
@@ -285,19 +288,29 @@ const getUserTransactionHistory = async (user_id, limit = 10, page = 1, groupbyd
285288
WHERE
286289
t.user_id = ?
287290
GROUP BY
288-
DATE(t.transaction_timestamp), t.item_id, t.custom_item_text, t.user_id, i.uuid, i.name, u.uuid, u.name, u.username, initiator.name, initiator.username
291+
DATE(t.transaction_timestamp), t.item_id, t.status, t.custom_item_text, t.user_id, i.uuid, i.name, u.uuid, u.name, u.username, initiator.name, initiator.username
289292
ORDER BY
290293
transaction_timestamp DESC
291294
LIMIT ? OFFSET ?
292295
`;
293296
} else {
294297
query = `
295298
SELECT
299+
t.id,
296300
t.user_id,
297301
t.item_id,
298302
t.quantity,
299303
t.price_at_transaction / 100.0 AS price_at_transaction,
300304
t.transaction_timestamp,
305+
t.status,
306+
CASE
307+
WHEN t.status = 0
308+
AND NOT (i.uuid = '13620506-b9f8-44d7-a9ff-d1b58ddee93f' AND t.custom_item_text IS NULL)
309+
AND (SELECT setting_value FROM app_settings WHERE setting_key = 'AUTO_REFUNDS_ACTIVE') = 'true'
310+
AND datetime(t.transaction_timestamp, '+' || COALESCE((SELECT setting_value FROM app_settings WHERE setting_key = 'AUTO_REFUNDS_MINUTES'), '0') || ' minutes') >= datetime('now')
311+
THEN 1
312+
ELSE 0
313+
END AS can_refund,
301314
t.custom_item_text,
302315
i.uuid AS item_uuid,
303316
i.name AS item_name,
@@ -338,11 +351,14 @@ const getAllTransactionHistory = async (limit = 10, page = 1, groupbyday = true)
338351
if (groupbyday) {
339352
query = `
340353
SELECT
354+
NULL AS id,
341355
t.user_id,
342356
t.item_id,
343357
SUM(t.quantity) AS quantity,
344358
SUM(t.price_at_transaction * t.quantity) / SUM(t.quantity) / 100.0 AS price_at_transaction,
345359
t.transaction_timestamp,
360+
t.status,
361+
0 AS can_refund,
346362
t.custom_item_text,
347363
i.uuid AS item_uuid,
348364
i.name AS item_name,
@@ -360,7 +376,7 @@ const getAllTransactionHistory = async (limit = 10, page = 1, groupbyday = true)
360376
JOIN
361377
users initiator ON t.initiator_id = initiator.id
362378
GROUP BY
363-
DATE(t.transaction_timestamp), t.item_id, t.custom_item_text, t.user_id, i.uuid, i.name, u.uuid, u.name, u.username, initiator.name, initiator.username
379+
DATE(t.transaction_timestamp), t.item_id, t.status, t.custom_item_text, t.user_id, i.uuid, i.name, u.uuid, u.name, u.username, initiator.name, initiator.username
364380
ORDER BY
365381
transaction_timestamp DESC
366382
LIMIT ? OFFSET ?
@@ -369,11 +385,19 @@ const getAllTransactionHistory = async (limit = 10, page = 1, groupbyday = true)
369385
} else {
370386
query = `
371387
SELECT
388+
t.id,
372389
t.user_id,
373390
t.item_id,
374391
t.quantity,
375392
t.price_at_transaction / 100.0 AS price_at_transaction,
376393
t.transaction_timestamp,
394+
t.status,
395+
CASE
396+
WHEN t.status = 0
397+
AND NOT (i.uuid = '13620506-b9f8-44d7-a9ff-d1b58ddee93f' AND t.custom_item_text IS NULL)
398+
THEN 1
399+
ELSE 0
400+
END AS can_refund,
377401
t.custom_item_text,
378402
i.uuid AS item_uuid,
379403
i.name AS item_name,
@@ -400,6 +424,71 @@ const getAllTransactionHistory = async (limit = 10, page = 1, groupbyday = true)
400424
return db.prepare(query).all(...params);
401425
}
402426

427+
const isAutoRefundAvailable = () => {
428+
const active = db.prepare('SELECT setting_value FROM app_settings WHERE setting_key = ?').get('AUTO_REFUNDS_ACTIVE');
429+
return active?.setting_value === 'true';
430+
};
431+
432+
const getAutoRefundMinutes = () => {
433+
const setting = db.prepare('SELECT setting_value FROM app_settings WHERE setting_key = ?').get('AUTO_REFUNDS_MINUTES');
434+
const minutes = Number(setting?.setting_value ?? 0);
435+
return Number.isFinite(minutes) && minutes > 0 ? minutes : 0;
436+
};
437+
438+
const refundTransaction = (transactionId, requesterId, { force = false } = {}) => {
439+
const runRefund = db.transaction((id, userId, forceRefund) => {
440+
const transaction = db.prepare(`
441+
SELECT
442+
t.id,
443+
t.user_id,
444+
t.item_id,
445+
t.quantity,
446+
t.price_at_transaction,
447+
t.transaction_timestamp,
448+
t.status,
449+
t.custom_item_text,
450+
i.uuid AS item_uuid
451+
FROM transactions t
452+
JOIN items i ON i.id = t.item_id
453+
WHERE t.id = ?
454+
`).get(id);
455+
456+
if (!transaction) return { success: false, status: 404, error: 'Transaction not found' };
457+
if (transaction.status === 1) return { success: false, status: 409, error: 'Transaction already refunded' };
458+
if (transaction.item_uuid === '13620506-b9f8-44d7-a9ff-d1b58ddee93f' && !transaction.custom_item_text) {
459+
return { success: false, status: 400, error: 'System transactions cannot be refunded' };
460+
}
461+
462+
if (!forceRefund) {
463+
if (transaction.user_id !== userId) return { success: false, status: 404, error: 'Transaction not found' };
464+
if (!isAutoRefundAvailable()) return { success: false, status: 403, error: 'Auto refunds disabled' };
465+
466+
const minutes = getAutoRefundMinutes();
467+
const timestamp = `${String(transaction.transaction_timestamp).replace(' ', 'T')}Z`;
468+
const ageMs = Date.now() - new Date(timestamp).getTime();
469+
if (!minutes || Number.isNaN(ageMs) || ageMs > minutes * 60 * 1000) {
470+
return { success: false, status: 403, error: 'Refund window expired' };
471+
}
472+
}
473+
474+
const refundAmount = transaction.price_at_transaction * transaction.quantity;
475+
const balanceDelta = transaction.item_uuid === '13620506-b9f8-44d7-a9ff-d1b58ddee93f' && !transaction.custom_item_text
476+
? refundAmount * -1
477+
: refundAmount;
478+
db.prepare('UPDATE users SET balance = balance + ? WHERE id = ?').run(balanceDelta, transaction.user_id);
479+
db.prepare('UPDATE items SET stock = stock + ? WHERE id = ? AND is_active = 1').run(transaction.quantity, transaction.item_id);
480+
db.prepare('UPDATE transactions SET status = 1 WHERE id = ?').run(transaction.id);
481+
db.prepare(`
482+
INSERT INTO refunds (user_id, transaction_id, authorizer_id, status, approved_timestamp)
483+
VALUES (?, ?, ?, 1, datetime('now'))
484+
`).run(transaction.user_id, transaction.id, userId);
485+
486+
return { success: true, refundedAmount: refundAmount };
487+
});
488+
489+
return runRefund(transactionId, requesterId, force);
490+
};
491+
403492
/**
404493
* Retrieves a user's favorite items.
405494
* @param {Number} user_id
@@ -578,6 +667,7 @@ module.exports = {
578667
purchaseItem,
579668
getUserTransactionHistory,
580669
getAllTransactionHistory,
670+
refundTransaction,
581671
getUserFavorites,
582672
updateUserUserName,
583673
updateUserUserNameByUUID,

seeder.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ INSERT OR IGNORE INTO app_settings (setting_key, setting_value) VALUES
1414
('LOW_FUNDS_STRING', 'Bitte lade dein Konto auf!'),
1515
('ERROR_REPORTS_ACTIVE', 'false'),
1616
('LOW_STOCK_WARNING', 'false'),
17-
('LOW_STOCK_PERCENT', '20');
17+
('LOW_STOCK_PERCENT', '20'),
18+
('AUTO_REFUNDS_ACTIVE', 'false'),
19+
('AUTO_REFUNDS_MINUTES', '10');
1820

1921
INSERT OR IGNORE INTO item_categories (uuid, name, is_active) VALUES
2022
('13620506-b9f8-44d7-a9ff-d1b58ddee93f', 'System', 2); -- 2 To Hide it

0 commit comments

Comments
 (0)