import type { FastifyInstance } from 'fastify'; import { Prisma } from '@prisma/client'; import { prisma } from '../../db.js'; import { env } from '../../env.js'; // Точка не подписывает webhook-ы; защита — секрет в URL (rotatable через .env), // raw-store перед обработкой, dedupe по комбинации (source, eventType, externalId). type TochkaWebhookPayload = { webhookType?: string; paymentId?: string; amount?: number; payerInn?: string; payerName?: string; purpose?: string; paidAt?: string; Data?: TochkaWebhookPayload; }; function flatten(p: TochkaWebhookPayload): TochkaWebhookPayload { return { ...p, ...(p.Data ?? {}) }; } function extractDocNumber(purpose: string | undefined): string | null { if (!purpose) return null; // популярные форматы: «Оплата по счёту № 123 от ...», «по счёту №ДГ-2026/001», «инвойс 123» const m = purpose.match(/(?:сч[её]т|инвойс|invoice|№)[^\dA-ZА-Я]*([A-ZА-Я0-9][A-ZА-Я0-9\-_/]{0,49})/i); return m && m[1] ? m[1].replace(/[.,;:]+$/, '') : null; } const PAYMENT_KIND_MAP: Record = { incomingPayment: 'incoming', incomingSbpPayment: 'incoming_sbp', incomingSbpB2BPayment: 'incoming_sbp_b2b', outgoingPayment: 'outgoing', }; export async function tochkaWebhookRoutes(app: FastifyInstance) { app.post('/webhooks/tochka/:secret', async (req, reply) => { const { secret } = req.params as { secret: string }; if (!env.TOCHKA_WEBHOOK_SECRET || secret !== env.TOCHKA_WEBHOOK_SECRET) { reply.code(404).send({ error: 'not_found' }); return; } const body = (req.body ?? {}) as TochkaWebhookPayload; const flat = flatten(body); const eventType = flat.webhookType ?? 'unknown'; const externalId = flat.paymentId ?? null; const dedupeKey = `tochka:${eventType}:${externalId ?? JSON.stringify(flat).slice(0, 100)}`; let event; try { event = await prisma.webhookEvent.create({ data: { source: 'tochka', eventType, dedupeKey, raw: body as Prisma.InputJsonValue, }, }); } catch (e) { if ((e as { code?: string }).code === 'P2002') { app.log.info({ dedupeKey }, 'webhook dedupe hit'); return { ok: true, deduped: true }; } throw e; } try { const kind = PAYMENT_KIND_MAP[eventType]; if (!kind || !externalId) { await prisma.webhookEvent.update({ where: { id: event.id }, data: { processedAt: new Date(), error: 'unsupported_event_type' }, }); return { ok: true, ignored: true }; } let docId: string | null = null; const docNumber = extractDocNumber(flat.purpose); if (docNumber) { const doc = await prisma.document.findFirst({ where: { number: docNumber, docType: 'invoice' }, select: { id: true, organizationId: true }, }); if (doc) docId = doc.id; } const fallbackOrg = await prisma.organization.findFirst({ where: { archivedAt: null }, select: { id: true } }); const orgId = docId ? (await prisma.document.findUnique({ where: { id: docId }, select: { organizationId: true } }))!.organizationId : fallbackOrg!.id; const amount = typeof flat.amount === 'number' ? Math.round(flat.amount * 100) : 0; try { await prisma.payment.create({ data: { organizationId: orgId, documentId: docId, tochkaPaymentId: externalId, kind, amountCents: BigInt(amount), payerInn: flat.payerInn ?? null, payerName: flat.payerName ?? null, purpose: flat.purpose ?? null, paidAt: flat.paidAt ? new Date(flat.paidAt) : null, raw: body as Prisma.InputJsonValue, }, }); } catch (e) { if ((e as { code?: string }).code !== 'P2002') throw e; } if (docId) { const doc = await prisma.document.findUnique({ where: { id: docId }, include: { payments: true }, }); if (doc) { const incoming = doc.payments .filter((p) => p.kind === 'incoming' || p.kind === 'incoming_sbp' || p.kind === 'incoming_sbp_b2b') .reduce((s, p) => s + p.amountCents, 0n); const newStatus = incoming >= doc.totalCents ? 'paid' : incoming > 0n ? 'partially_paid' : doc.status; if (newStatus !== doc.status) { await prisma.document.update({ where: { id: docId }, data: { status: newStatus } }); } // Если документ привязан к Order через project → обновить Order.status if (doc.projectId) { const order = await prisma.order.findFirst({ where: { projectId: doc.projectId }, select: { id: true }, }); if (order && newStatus === 'paid') { await prisma.order.update({ where: { id: order.id }, data: { status: 'paid' } }); } } } } await prisma.webhookEvent.update({ where: { id: event.id }, data: { processedAt: new Date() }, }); return { ok: true, matchedDocument: docId }; } catch (e) { const msg = (e as Error).message ?? 'unknown'; app.log.error({ err: e, dedupeKey }, 'webhook processing failed'); await prisma.webhookEvent.update({ where: { id: event.id }, data: { error: msg.slice(0, 500) }, }); return { ok: false, error: 'processing_failed' }; } }); }