Шаг 0.13 плана production-готовности: - интерфейс PaymentProvider (createPayment/verifyWebhook/getPaymentStatus) - MockPaymentProvider с настоящим webhook (sha256-подпись, идемпотентность) - applySucceededPayment: транзакция с guard, credit, авто-снятие billingBlocked - роуты payments + confirm-test (только mock) + публичный webhook - Billing.tsx: Пополнить/Подтвердить (тест), фикс setLocation в рендере - миграция 0080 billing_payments, 13 новых тестов (78/78)
334 lines
16 KiB
TypeScript
334 lines
16 KiB
TypeScript
import { Router } from "express";
|
|
import { storage } from "../storage";
|
|
import { requireSuperAdmin, authenticateToken, requirePermission, type AuthenticatedRequest, type SuperAdminRequest } from "../middleware/auth.middleware";
|
|
import { authLimiter } from "./shared";
|
|
const router = Router();
|
|
|
|
// =====================
|
|
// Super Admin Endpoints
|
|
// =====================
|
|
|
|
// GET /api/superadmin/exists — public, returns whether any super admin exists
|
|
router.get('/api/superadmin/exists', async (_req, res) => {
|
|
try {
|
|
const count = await storage.getSuperAdminCount();
|
|
return res.json({ exists: count > 0 });
|
|
} catch (error) {
|
|
console.error('SuperAdmin exists check error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/seed — create first super admin (only if none exist)
|
|
router.post('/api/superadmin/seed', authLimiter, async (req, res) => {
|
|
try {
|
|
const { superAdminService } = await import('../services/superadmin.service');
|
|
const { email, password, name, seedToken } = req.body;
|
|
if (!email || !password || !name) {
|
|
return res.status(400).json({ success: false, error: 'Необходимы email, password и name' });
|
|
}
|
|
|
|
// If there are already super admins — always reject
|
|
const count = await storage.getSuperAdminCount();
|
|
if (count > 0) {
|
|
return res.status(409).json({ success: false, error: 'Суперадмин уже существует' });
|
|
}
|
|
|
|
// If SUPERADMIN_SEED_TOKEN is set — require it; otherwise allow without token (first-run UI flow)
|
|
const expectedToken = process.env.SUPERADMIN_SEED_TOKEN;
|
|
if (expectedToken && seedToken !== expectedToken) {
|
|
return res.status(403).json({ success: false, error: 'Неверный seed token' });
|
|
}
|
|
|
|
const result = await superAdminService.seedFirstSuperAdmin(email, password, name);
|
|
if (result.success) {
|
|
// Auto-login: return a token so the UI can redirect immediately
|
|
const loginResult = await superAdminService.login(email, password);
|
|
if (loginResult.success) {
|
|
return res.status(201).json({ success: true, token: loginResult.token, superAdmin: loginResult.superAdmin });
|
|
}
|
|
return res.status(201).json({ success: true });
|
|
}
|
|
return res.status(400).json({ success: false, error: result.error });
|
|
} catch (error) {
|
|
console.error('SuperAdmin seed error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/forgot-password — request password reset link
|
|
router.post('/api/superadmin/forgot-password', authLimiter, async (req, res) => {
|
|
try {
|
|
const { superAdminService } = await import('../services/superadmin.service');
|
|
const { email } = req.body;
|
|
if (!email) {
|
|
return res.status(400).json({ success: false, error: 'Необходим email' });
|
|
}
|
|
await superAdminService.requestPasswordReset(email);
|
|
return res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('SuperAdmin forgot password error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/reset-password — confirm reset with token and new password
|
|
router.post('/api/superadmin/reset-password', authLimiter, async (req, res) => {
|
|
try {
|
|
const { superAdminService } = await import('../services/superadmin.service');
|
|
const { token, password } = req.body;
|
|
if (!token || !password) {
|
|
return res.status(400).json({ success: false, error: 'Необходимы token и password' });
|
|
}
|
|
if (password.length < 8) {
|
|
return res.status(400).json({ success: false, error: 'Пароль должен быть не менее 8 символов' });
|
|
}
|
|
const result = await superAdminService.confirmPasswordReset(token, password);
|
|
if (result.success) {
|
|
return res.json({ success: true });
|
|
}
|
|
return res.status(400).json({ success: false, error: result.error });
|
|
} catch (error) {
|
|
console.error('SuperAdmin reset password error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/login
|
|
router.post('/api/superadmin/login', authLimiter, async (req, res) => {
|
|
try {
|
|
const { superAdminService } = await import('../services/superadmin.service');
|
|
const { email, password } = req.body;
|
|
if (!email || !password) {
|
|
return res.status(400).json({ success: false, error: 'Необходимы email и password' });
|
|
}
|
|
const result = await superAdminService.login(email, password);
|
|
if (result.success) {
|
|
return res.json({ success: true, token: result.token, superAdmin: result.superAdmin });
|
|
}
|
|
return res.status(401).json({ success: false, error: result.error });
|
|
} catch (error) {
|
|
console.error('SuperAdmin login error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// GET /api/superadmin/admins — list all super admins
|
|
router.get('/api/superadmin/admins', requireSuperAdmin, async (_req, res) => {
|
|
try {
|
|
const admins = await storage.getAllSuperAdmins();
|
|
const safe = admins.map(a => ({ id: a.id, email: a.email, name: a.name, createdAt: a.createdAt, lastLogin: a.lastLogin }));
|
|
return res.json({ success: true, admins: safe });
|
|
} catch (error) {
|
|
console.error('SuperAdmin list error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/admins — create additional super admin
|
|
router.post('/api/superadmin/admins', requireSuperAdmin, authLimiter, async (req, res) => {
|
|
try {
|
|
const { superAdminService } = await import('../services/superadmin.service');
|
|
const { email, password, name } = req.body;
|
|
if (!email || !password || !name) {
|
|
return res.status(400).json({ success: false, error: 'Необходимы email, password и name' });
|
|
}
|
|
if (password.length < 8) {
|
|
return res.status(400).json({ success: false, error: 'Пароль должен быть не менее 8 символов' });
|
|
}
|
|
const result = await superAdminService.createAdditionalSuperAdmin(email, password, name);
|
|
if (result.success) {
|
|
return res.status(201).json({ success: true, superAdmin: result.superAdmin });
|
|
}
|
|
return res.status(400).json({ success: false, error: result.error });
|
|
} catch (error) {
|
|
console.error('SuperAdmin create error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/superadmin/admins/:id — delete super admin
|
|
router.delete('/api/superadmin/admins/:id', requireSuperAdmin, async (req: SuperAdminRequest, res) => {
|
|
try {
|
|
const { superAdminService } = await import('../services/superadmin.service');
|
|
const idToDelete = parseInt(req.params.id);
|
|
if (isNaN(idToDelete)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
const currentId = req.superAdmin?.id ?? 0;
|
|
const result = await superAdminService.deleteSuperAdmin(idToDelete, currentId);
|
|
if (result.success) {
|
|
return res.json({ success: true });
|
|
}
|
|
return res.status(400).json({ success: false, error: result.error });
|
|
} catch (error) {
|
|
console.error('SuperAdmin delete error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// GET /api/superadmin/organizations — list all with stats
|
|
// requireSuperAdmin already sets up the SA db context (app.is_superadmin='true')
|
|
// via AsyncLocalStorage, so storage.getAllOrganizationsWithStats() automatically
|
|
// bypasses tenant RLS policies without any additional wrapping here.
|
|
router.get('/api/superadmin/organizations', requireSuperAdmin, async (_req, res) => {
|
|
try {
|
|
const orgs = await storage.getAllOrganizationsWithStats();
|
|
return res.json({ success: true, organizations: orgs });
|
|
} catch (error) {
|
|
console.error('SuperAdmin orgs error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// GET /api/superadmin/organizations/:id/stats
|
|
router.get('/api/superadmin/organizations/:id/stats', requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
|
|
const [org, stats] = await Promise.all([
|
|
storage.getOrganization(orgId),
|
|
storage.getOrganizationDetailedStats(orgId),
|
|
]);
|
|
if (!org) return res.status(404).json({ success: false, error: 'Организация не найдена' });
|
|
|
|
return res.json({ success: true, organization: org, stats });
|
|
} catch (error) {
|
|
console.error('SuperAdmin org stats error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// PATCH /api/superadmin/organizations/:id — update isActive etc
|
|
router.patch('/api/superadmin/organizations/:id', requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
|
|
const { isActive } = req.body;
|
|
if (typeof isActive !== 'boolean') {
|
|
return res.status(400).json({ success: false, error: 'Поле isActive должно быть булевым значением' });
|
|
}
|
|
|
|
const updates: { isActive: boolean } = { isActive };
|
|
const org = await storage.updateOrganization(orgId, updates);
|
|
if (!org) return res.status(404).json({ success: false, error: 'Организация не найдена' });
|
|
return res.json({ success: true, organization: org });
|
|
} catch (error) {
|
|
console.error('SuperAdmin update org error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// =====================
|
|
// Billing — Super Admin
|
|
// =====================
|
|
|
|
// GET /api/superadmin/organizations/:id/billing
|
|
router.get('/api/superadmin/organizations/:id/billing', requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
const org = await storage.getOrganization(orgId);
|
|
if (!org) return res.status(404).json({ success: false, error: 'Организация не найдена' });
|
|
const billing = await storage.getOrCreateBillingSettings(orgId);
|
|
return res.json({ success: true, billing, organization: org });
|
|
} catch (error) {
|
|
console.error('SuperAdmin billing get error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/organizations/:id/billing/topup — пополнить баланс
|
|
router.post('/api/superadmin/organizations/:id/billing/topup', requireSuperAdmin, async (req: SuperAdminRequest, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
const { amount, description } = req.body as { amount: unknown; description?: string };
|
|
const amountNum = parseFloat(String(amount));
|
|
if (!amount || isNaN(amountNum) || amountNum <= 0) {
|
|
return res.status(400).json({ success: false, error: 'Сумма должна быть положительным числом' });
|
|
}
|
|
const tx = await storage.addBillingCredit(orgId, amountNum, description || 'Пополнение баланса', req.superAdmin?.id);
|
|
return res.json({ success: true, transaction: tx });
|
|
} catch (error) {
|
|
console.error('SuperAdmin billing topup error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// PATCH /api/superadmin/organizations/:id/billing — изменить настройки (pricePerUser, currency)
|
|
router.patch('/api/superadmin/organizations/:id/billing', requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
const { pricePerUser, currency } = req.body as { pricePerUser?: unknown; currency?: unknown };
|
|
const updates: { pricePerUser?: string; currency?: string } = {};
|
|
if (pricePerUser !== undefined) {
|
|
const p = parseFloat(String(pricePerUser));
|
|
if (isNaN(p) || p < 0) return res.status(400).json({ success: false, error: 'Некорректная цена' });
|
|
updates.pricePerUser = p.toFixed(2);
|
|
}
|
|
if (currency) updates.currency = String(currency).slice(0, 10);
|
|
const billing = await storage.updateBillingSettings(orgId, updates);
|
|
return res.json({ success: true, billing });
|
|
} catch (error) {
|
|
console.error('SuperAdmin billing update error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// POST /api/superadmin/organizations/:id/billing/unblock — снять блокировку
|
|
router.post('/api/superadmin/organizations/:id/billing/unblock', requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
await storage.setBillingBlocked(orgId, false);
|
|
return res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('SuperAdmin billing unblock error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// GET /api/superadmin/organizations/:id/billing/transactions
|
|
router.get('/api/superadmin/organizations/:id/billing/transactions', requireSuperAdmin, async (req, res) => {
|
|
try {
|
|
const orgId = parseInt(req.params.id);
|
|
if (isNaN(orgId)) return res.status(400).json({ success: false, error: 'Некорректный ID' });
|
|
const transactions = await storage.getBillingTransactions(orgId, 50);
|
|
return res.json({ success: true, transactions });
|
|
} catch (error) {
|
|
console.error('SuperAdmin billing transactions error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// =====================
|
|
// Billing — Org Admin
|
|
// =====================
|
|
|
|
// GET /api/billing/summary — текущий баланс, активные пользователи и статус
|
|
router.get('/api/billing/summary', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const summary = await storage.getBillingSummary(req.organizationId!);
|
|
const { getPaymentProviderName } = await import('../billing/payment-provider');
|
|
return res.json({ success: true, summary: { ...summary, paymentProvider: getPaymentProviderName() } });
|
|
} catch (error) {
|
|
console.error('Billing summary error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
// GET /api/billing/transactions — история транзакций
|
|
router.get('/api/billing/transactions', authenticateToken, requirePermission('billing.manage'), async (req: AuthenticatedRequest, res) => {
|
|
try {
|
|
const transactions = await storage.getBillingTransactions(req.organizationId!, 20);
|
|
return res.json({ success: true, transactions });
|
|
} catch (error) {
|
|
console.error('Billing transactions error:', error);
|
|
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
|
}
|
|
});
|
|
|
|
export default router;
|