Files
doc-manager/apps/api/src/modules/tochka/webhook.routes.ts
T
admin c2fcdec85d feat(orders): Site/Order/OrderItem + S2S incoming endpoint + Tochka webhook receiver
Schema:
- Site (organizationId, name, slug, domain, apiKey, defaultOfferTemplateId)
- Order (full customer fields, status enum, totalCents/vatCents, projectId link, rawPayload)
- OrderItem (orderId, position, name, serviceId, qty, unit, price, vat, eventDate)
- Migration 3_orders + OrderStatus enum

API:
- /api/sites — CRUD with apiKey shown only on create/regenerate
- /api/orders — list/get/convert-to-project (creates project + matches/creates client by INN)
- POST /api/incoming/orders — S2S, X-Site-Key header → resolves Site → creates Order
- POST /webhooks/tochka/<secret> — receives raw, dedupes, parses paymentId+purpose,
  matches by document number regex, creates Payment, updates Document status
  (paid/partially_paid), propagates Order.status=paid when fully covered

Web:
- /sites page: list + add site (paste-friendly modal with API key + curl example
  shown once after create/regenerate)
- /orders page: filterable list, link to project
- /orders/🆔 view with items + "Перевести в проект" button (creates project,
  upserts client by INN, links project<-order)
- Nav: «Заявки» and «Сайты» added

Manual demo flow:
1. /sites → add «Голосования» slug=voting → save the apiKey
2. curl POST /api/incoming/orders with X-Site-Key → order appears in /orders
3. Open order → «Перевести в проект» → project created with client+default
4. Create invoice document in project → «Выставить через Точку»
5. Webhook from sandbox/prod → document.status=paid → order.status=paid

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 15:00:24 +03:00

160 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, 'incoming' | 'incoming_sbp' | 'incoming_sbp_b2b' | 'outgoing'> = {
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' };
}
});
}