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:
360
tests/billing-payments.test.ts
Normal file
360
tests/billing-payments.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user