feat(billing): PaymentProvider + Mock, self-service пополнение баланса

Шаг 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)
This commit is contained in:
2026-09-07 22:45:30 +03:00
parent 23c49adcd1
commit 701af06290
11 changed files with 1039 additions and 14 deletions

View File

@@ -0,0 +1,360 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
const mockStorage = vi.hoisted(() => ({
getUserWithOrganization: vi.fn(),
}));
// In-memory fake payments.service: воспроизводит контракт настоящего сервиса,
// включая идемпотентность зачисления (pending→succeeded только один раз).
const fake = vi.hoisted(() => {
const state = {
payments: new Map<number, any>(),
nextId: 1,
credits: [] as Array<{ organizationId: number; amount: string }>,
};
return { state };
});
vi.mock('../server/db', () => ({
db: {
// Цепочка для loadPermissionCache в auth.middleware (requirePermission).
select: () => ({
from: () => ({
innerJoin: () => ({
innerJoin: () => Promise.resolve([{ appRoleSlug: 'admin', permissionCode: 'billing.manage' }]),
}),
}),
}),
// Заглушка для trackUserActivity (best-effort обновление активности).
update: () => ({
set: () => ({
where: () => Promise.resolve(),
}),
}),
},
pool: { query: vi.fn().mockResolvedValue({ rows: [] }) },
withTenant: (_orgId: number, fn: () => unknown) => fn(),
openTenantCtx: vi.fn().mockResolvedValue({
run: (fn: () => void) => fn(),
release: vi.fn(),
}),
openSuperAdminCtx: vi.fn().mockResolvedValue(undefined),
_tenantCtx: { getStore: vi.fn().mockReturnValue(null) },
}));
vi.mock('../server/storage', () => ({ storage: mockStorage }));
vi.mock('../server/services/notification.service', () => ({
notificationService: {
emit: vi.fn(),
on: vi.fn(),
sendNotification: vi.fn().mockResolvedValue(undefined),
processEvent: vi.fn().mockResolvedValue(undefined),
},
EVENT_TYPES: {},
}));
vi.mock('../server/services/webhook.service', () => ({
webhookService: {
dispatchEvent: vi.fn().mockResolvedValue(undefined),
processWebhook: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../server/utils/webhook', () => ({
sendWebhook: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../server/utils/s3', () => ({
isS3Enabled: false,
ensureS3Bucket: vi.fn().mockResolvedValue(undefined),
streamFromS3: vi.fn().mockResolvedValue(null),
deleteFromS3: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../server/utils/audit', () => ({
logAudit: vi.fn().mockResolvedValue(undefined),
getClientIp: vi.fn().mockReturnValue('127.0.0.1'),
}));
vi.mock('../server/billing/payments.service', () => ({
createBillingPayment: vi.fn(async (p: any) => {
for (const row of fake.state.payments.values()) {
if (row.idempotencyKey === p.idempotencyKey) return row;
}
const row = {
id: fake.state.nextId++,
status: 'pending',
...p,
amount: p.amount.toFixed(2),
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
fake.state.payments.set(row.id, row);
return row;
}),
listBillingPayments: vi.fn(async (orgId: number) =>
[...fake.state.payments.values()].filter((p) => p.organizationId === orgId)),
getBillingPaymentById: vi.fn(async (id: number, orgId?: number) => {
const p = fake.state.payments.get(id) ?? null;
return p && (orgId === undefined || p.organizationId === orgId) ? p : null;
}),
getBillingPaymentByExternalId: vi.fn(async (externalId: string) =>
[...fake.state.payments.values()].find((p) => p.externalId === externalId) ?? null),
markBillingPaymentCanceled: vi.fn(async (id: number) => {
const p = fake.state.payments.get(id) ?? null;
if (p && p.status === 'pending') p.status = 'canceled';
return p;
}),
applySucceededPayment: vi.fn(async (id: number) => {
const p = fake.state.payments.get(id) ?? null;
if (!p || p.status !== 'pending') return { applied: false, payment: p };
p.status = 'succeeded';
fake.state.credits.push({ organizationId: p.organizationId, amount: p.amount });
return { applied: true, payment: p };
}),
}));
import express from 'express';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import billingPaymentsRouter from '../server/routes/billing-payments.routes';
import { signMockWebhook } from '../server/billing/mock-provider';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET as string;
function makeValidToken(userId: number, organizationId: number): string {
return jwt.sign(
{ userId, organizationId, role: 'user' },
ACCESS_SECRET,
{ issuer: 'workflow-system', audience: 'workflow-users', expiresIn: '1h' },
);
}
function buildApp() {
const app = express();
app.use(express.json());
app.use(billingPaymentsRouter);
return app;
}
const TEST_USER = {
id: 42,
email: 'admin@example.com',
firstName: 'Admin',
lastName: 'User',
appRole: 'admin',
role: 'user',
isActive: true,
organizationId: 7,
organization: { id: 7, isActive: true, billingBlocked: true },
};
const app = buildApp();
function authHeader(): [string, string] {
return ['Authorization', `Bearer ${makeValidToken(TEST_USER.id, TEST_USER.organizationId)}`];
}
async function createPayment(amount = 1500): Promise<any> {
const res = await request(app)
.post('/api/billing/payments')
.set(...authHeader())
.send({ amount });
expect(res.status).toBe(201);
return res.body.payment;
}
beforeEach(() => {
fake.state.payments.clear();
fake.state.nextId = 1;
fake.state.credits.length = 0;
mockStorage.getUserWithOrganization.mockResolvedValue(TEST_USER);
delete process.env.PAYMENT_PROVIDER;
});
afterEach(() => {
delete process.env.PAYMENT_PROVIDER;
});
describe('POST /api/billing/payments', () => {
it('создаёт платёж в статусе pending (201)', async () => {
const res = await request(app)
.post('/api/billing/payments')
.set(...authHeader())
.send({ amount: 1500 });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.payment).toMatchObject({
organizationId: TEST_USER.organizationId,
provider: 'mock',
status: 'pending',
amount: '1500.00',
currency: 'RUB',
});
expect(res.body.payment.externalId).toMatch(/^mock_/);
});
it('возвращает 401 без токена', async () => {
const res = await request(app).post('/api/billing/payments').send({ amount: 100 });
expect(res.status).toBe(401);
});
it('возвращает 400 при некорректной сумме', async () => {
const res = await request(app)
.post('/api/billing/payments')
.set(...authHeader())
.send({ amount: -50 });
expect(res.status).toBe(400);
});
it('повторный запрос с тем же Idempotency-Key возвращает тот же платёж', async () => {
const first = await request(app)
.post('/api/billing/payments')
.set(...authHeader())
.set('Idempotency-Key', 'topup-abc-1')
.send({ amount: 500 });
const second = await request(app)
.post('/api/billing/payments')
.set(...authHeader())
.set('Idempotency-Key', 'topup-abc-1')
.send({ amount: 500 });
expect(first.status).toBe(201);
expect(second.status).toBe(201);
expect(second.body.payment.id).toBe(first.body.payment.id);
expect(fake.state.payments.size).toBe(1);
});
});
describe('GET /api/billing/payments', () => {
it('возвращает платежи только своей организации', async () => {
await createPayment(100);
await createPayment(200);
const res = await request(app)
.get('/api/billing/payments')
.set(...authHeader());
expect(res.status).toBe(200);
expect(res.body.payments).toHaveLength(2);
});
});
describe('POST /api/billing/payments/:id/confirm-test', () => {
it('подтверждает платёж и зачисляет средства (снятие блокировки — внутри зачисления)', async () => {
const payment = await createPayment(1500);
const res = await request(app)
.post(`/api/billing/payments/${payment.id}/confirm-test`)
.set(...authHeader());
expect(res.status).toBe(200);
expect(res.body.applied).toBe(true);
expect(res.body.payment.status).toBe('succeeded');
expect(fake.state.credits).toEqual([
{ organizationId: TEST_USER.organizationId, amount: '1500.00' },
]);
});
it('возвращает 404 для платежа чужой организации', async () => {
const payment = await createPayment(100);
const otherToken = makeValidToken(55, 999);
mockStorage.getUserWithOrganization.mockResolvedValue({
...TEST_USER,
id: 55,
organizationId: 999,
organization: { id: 999, isActive: true, billingBlocked: false },
});
const res = await request(app)
.post(`/api/billing/payments/${payment.id}/confirm-test`)
.set('Authorization', `Bearer ${otherToken}`);
expect(res.status).toBe(404);
expect(fake.state.credits).toHaveLength(0);
});
it('возвращает 404 при не-mock провайдере', async () => {
const payment = await createPayment(100);
process.env.PAYMENT_PROVIDER = 'yookassa';
const res = await request(app)
.post(`/api/billing/payments/${payment.id}/confirm-test`)
.set(...authHeader());
expect(res.status).toBe(404);
expect(fake.state.credits).toHaveLength(0);
});
});
describe('POST /api/billing/webhooks/:provider', () => {
it('webhook с валидной подписью зачисляет платёж', async () => {
const payment = await createPayment(1500);
const res = await request(app)
.post('/api/billing/webhooks/mock')
.set('x-mock-signature', signMockWebhook(payment.externalId))
.send({ externalId: payment.externalId, status: 'succeeded' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(fake.state.payments.get(payment.id)?.status).toBe('succeeded');
expect(fake.state.credits).toEqual([
{ organizationId: TEST_USER.organizationId, amount: '1500.00' },
]);
});
it('повторный webhook не зачисляет дважды (идемпотентность)', async () => {
const payment = await createPayment(1500);
const signature = signMockWebhook(payment.externalId);
const body = { externalId: payment.externalId, status: 'succeeded' };
const first = await request(app)
.post('/api/billing/webhooks/mock')
.set('x-mock-signature', signature)
.send(body);
const second = await request(app)
.post('/api/billing/webhooks/mock')
.set('x-mock-signature', signature)
.send(body);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(fake.state.credits).toHaveLength(1);
});
it('webhook с невалидной подписью отклоняется (401)', async () => {
const payment = await createPayment(1500);
const res = await request(app)
.post('/api/billing/webhooks/mock')
.set('x-mock-signature', 'deadbeef'.repeat(8))
.send({ externalId: payment.externalId, status: 'succeeded' });
expect(res.status).toBe(401);
expect(fake.state.payments.get(payment.id)?.status).toBe('pending');
expect(fake.state.credits).toHaveLength(0);
});
it('webhook без подписи отклоняется (401)', async () => {
const payment = await createPayment(1500);
const res = await request(app)
.post('/api/billing/webhooks/mock')
.send({ externalId: payment.externalId, status: 'succeeded' });
expect(res.status).toBe(401);
expect(fake.state.credits).toHaveLength(0);
});
it('webhook для неизвестного провайдера возвращает 404', async () => {
const res = await request(app)
.post('/api/billing/webhooks/unknown-provider')
.send({ externalId: 'mock_whatever', status: 'succeeded' });
expect(res.status).toBe(404);
});
});