feat: promo codes system
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()
This commit is contained in:
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user