c2fcdec85d
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>
160 lines
5.6 KiB
TypeScript
160 lines
5.6 KiB
TypeScript
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' };
|
||
}
|
||
});
|
||
}
|