From ce74ac9909ea8e65cdeb436d4a91b6e2966aeb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Sat, 13 Jun 2026 09:36:32 +0300 Subject: [PATCH] feat: promo codes system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB: promo_codes, promo_usages tables routes/admin.js: CRUD /api/admin/promos (GET/POST/PATCH/DELETE) routes/billing.js: POST /api/billing/apply-promo Валидация: exists, active, not expired, not exhausted, not used by this user type=credits → начисляет через billing.credit() --- src/routes/admin.js | 60 +++++++++++++++++++++++++++++++++++++++++++ src/routes/billing.js | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/routes/admin.js b/src/routes/admin.js index 1ebd7b0..47c66f8 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -173,3 +173,63 @@ router.patch('/users/:id', async (req, res) => { res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); + +// ── PROMO CODES ────────────────────────────────────────────── + +// GET /api/admin/promos — список промокодов +router.get('/promos', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const { rows } = await query(` + SELECT p.*, count(pu.id)::int as uses_real + FROM promo_codes p + LEFT JOIN promo_usages pu ON pu.code_id = p.id + GROUP BY p.id ORDER BY p.created_at DESC + `); + res.json(rows); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/admin/promos — создать промокод +router.post('/promos', async (req, res) => { + if (!await requireAdmin(req, res)) return; + const { code, type = 'credits', value, max_uses = 1, expires_at, description } = req.body; + if (!code || !value) return res.status(400).json({ error: 'code и value обязательны' }); + if (!['credits','discount_pct'].includes(type)) return res.status(400).json({ error: 'type: credits | discount_pct' }); + try { + const { rows: [promo] } = await query(` + INSERT INTO promo_codes (code, type, value, max_uses, expires_at, description) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING * + `, [code.toUpperCase(), type, value, max_uses, expires_at || null, description || null]); + res.json(promo); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'Такой промокод уже существует' }); + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/admin/promos/:id — обновить (деактивировать и т.п.) +router.patch('/promos/:id', async (req, res) => { + if (!await requireAdmin(req, res)) return; + const { is_active, description, max_uses, expires_at } = req.body; + try { + const sets = []; const vals = []; + if (is_active !== undefined) sets.push(`is_active=$${vals.push(is_active)}`); + if (description !== undefined) sets.push(`description=$${vals.push(description)}`); + if (max_uses !== undefined) sets.push(`max_uses=$${vals.push(max_uses)}`); + if (expires_at !== undefined) sets.push(`expires_at=$${vals.push(expires_at)}`); + if (!sets.length) return res.status(400).json({ error: 'nothing to update' }); + vals.push(req.params.id); + const { rows: [p] } = await query(`UPDATE promo_codes SET ${sets.join(',')} WHERE id=$${vals.length} RETURNING *`, vals); + res.json(p); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/admin/promos/:id +router.delete('/promos/:id', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + await query('DELETE FROM promo_codes WHERE id=$1', [req.params.id]); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); diff --git a/src/routes/billing.js b/src/routes/billing.js index a418afe..c6762a3 100644 --- a/src/routes/billing.js +++ b/src/routes/billing.js @@ -204,3 +204,53 @@ router.get('/admin/dashboard', async (req, res) => { }); } catch (err) { res.status(500).json({ error: err.message }); } }); + +// POST /api/billing/apply-promo — применить промокод +router.post('/apply-promo', async (req, res) => { + const userId = uid(req); + if (!userId) return res.status(401).json({ error: 'x-user-id required' }); + const { code } = req.body; + if (!code?.trim()) return res.status(400).json({ error: 'code обязателен' }); + + try { + // Ищем промокод + const { rows: [promo] } = await query(` + SELECT p.*, count(pu.id)::int as uses_real + FROM promo_codes p + LEFT JOIN promo_usages pu ON pu.code_id = p.id + WHERE p.code = $1 AND p.is_active = true + AND (p.expires_at IS NULL OR p.expires_at > NOW()) + GROUP BY p.id + `, [code.toUpperCase()]); + + if (!promo) return res.status(404).json({ error: 'Промокод не найден или истёк' }); + if (promo.max_uses !== -1 && promo.uses_real >= promo.max_uses) + return res.status(410).json({ error: 'Промокод исчерпан' }); + + // Проверяем что пользователь ещё не использовал + const { rows: [used] } = await query( + 'SELECT id FROM promo_usages WHERE code_id=$1 AND user_id=$2', + [promo.id, userId] + ); + if (used) return res.status(409).json({ error: 'Вы уже использовали этот промокод' }); + + // Применяем + const billing = require('../services/billing'); + if (promo.type === 'credits') { + await billing.credit(userId, promo.value, 'bonus', `Промокод ${promo.code}`, { promo_id: promo.id }); + } + + // Записываем использование + await query('INSERT INTO promo_usages (code_id, user_id) VALUES ($1,$2)', [promo.id, userId]); + await query('UPDATE promo_codes SET used_count = used_count + 1 WHERE id=$1', [promo.id]); + + res.json({ + ok: true, + type: promo.type, + value: promo.value, + message: promo.type === 'credits' + ? `+${promo.value} кредитов начислено на ваш баланс` + : `Скидка ${promo.value}% применена`, + }); + } catch (err) { res.status(500).json({ error: err.message }); } +});