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>
This commit is contained in:
admin
2026-06-16 15:00:24 +03:00
parent 0c6deed98d
commit c2fcdec85d
10 changed files with 1169 additions and 0 deletions
@@ -0,0 +1,159 @@
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' };
}
});
}