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:
Ник (Claude)
2026-06-13 09:36:32 +03:00
parent 2360e1f7ae
commit ce74ac9909
2 changed files with 110 additions and 0 deletions
+60
View File
@@ -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 }); }
});