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,87 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('new', 'accepted', 'invoiced', 'paid', 'fulfilled', 'cancelled');
-- CreateTable Site
CREATE TABLE "Site" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"domain" TEXT,
"apiKey" TEXT NOT NULL,
"defaultOfferTemplateId" UUID,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Site_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Site_apiKey_key" ON "Site"("apiKey");
CREATE UNIQUE INDEX "Site_organizationId_slug_key" ON "Site"("organizationId", "slug");
CREATE INDEX "Site_organizationId_idx" ON "Site"("organizationId");
ALTER TABLE "Site" ADD CONSTRAINT "Site_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Site" ADD CONSTRAINT "Site_defaultOfferTemplateId_fkey"
FOREIGN KEY ("defaultOfferTemplateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable Order
CREATE TABLE "Order" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"siteId" UUID,
"projectId" UUID,
"status" "OrderStatus" NOT NULL DEFAULT 'new',
"customerName" TEXT NOT NULL,
"customerInn" TEXT,
"customerKpp" TEXT,
"customerEmail" TEXT,
"customerPhone" TEXT,
"customerAddress" TEXT,
"customerKind" "ClientKind" NOT NULL DEFAULT 'ul',
"totalCents" BIGINT NOT NULL DEFAULT 0,
"vatCents" BIGINT NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL DEFAULT 'RUB',
"acceptedOfferAt" TIMESTAMP(3),
"notes" TEXT,
"rawPayload" JSONB,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Order_organizationId_status_idx" ON "Order"("organizationId", "status");
CREATE INDEX "Order_organizationId_siteId_idx" ON "Order"("organizationId", "siteId");
CREATE INDEX "Order_projectId_idx" ON "Order"("projectId");
ALTER TABLE "Order" ADD CONSTRAINT "Order_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Order" ADD CONSTRAINT "Order_siteId_fkey"
FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Order" ADD CONSTRAINT "Order_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable OrderItem
CREATE TABLE "OrderItem" (
"id" UUID NOT NULL,
"orderId" UUID NOT NULL,
"position" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"serviceId" UUID,
"qtyMilli" BIGINT NOT NULL DEFAULT 1000,
"unit" TEXT NOT NULL DEFAULT 'шт',
"priceCents" BIGINT NOT NULL,
"vat" "VatRate" NOT NULL DEFAULT 'none',
"sumCents" BIGINT NOT NULL,
"eventDate" TIMESTAMP(3),
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
CREATE INDEX "OrderItem_serviceId_idx" ON "OrderItem"("serviceId");
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey"
FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_serviceId_fkey"
FOREIGN KEY ("serviceId") REFERENCES "ServiceCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+87
View File
@@ -61,6 +61,15 @@ enum ProjectStatus {
cancelled
}
enum OrderStatus {
new // только пришла с сайта
accepted // менеджер принял (или клиент акцептовал оферту)
invoiced // выставлен счёт
paid // оплачено
fulfilled // услуга оказана, документы закрыты
cancelled
}
model Organization {
id String @id @default(uuid()) @db.Uuid
name String
@@ -89,6 +98,81 @@ model Organization {
auditLog AuditLog[]
bankAccounts BankAccount[]
projects Project[]
sites Site[]
orders Order[]
}
model Site {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String // напр. "Голосования", "PrezLoad"
slug String // напр. "voting", "prezload" — используется в URL и человекочитаемых местах
domain String? // напр. "voting.queo.ru" — для верификации/CORS
apiKey String @unique // S2S ключ (длинная строка)
defaultOfferTemplateId String? @db.Uuid
defaultOfferTemplate DocumentTemplate? @relation("OfferTemplate", fields: [defaultOfferTemplateId], references: [id])
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
@@unique([organizationId, slug])
@@index([organizationId])
}
model Order {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
siteId String? @db.Uuid
site Site? @relation(fields: [siteId], references: [id])
projectId String? @db.Uuid // когда сконвертирована в проект
project Project? @relation(fields: [projectId], references: [id])
status OrderStatus @default(new)
// данные клиента из заявки (могут не совпадать с записью Client — пока не привязан)
customerName String
customerInn String?
customerKpp String?
customerEmail String?
customerPhone String?
customerAddress String?
customerKind ClientKind @default(ul)
totalCents BigInt @default(0)
vatCents BigInt @default(0)
currency String @default("RUB")
acceptedOfferAt DateTime? // когда клиент принял оферту на сайте
notes String?
rawPayload Json? // что прислал сайт целиком — на случай неполного парсинга
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items OrderItem[]
@@index([organizationId, status])
@@index([organizationId, siteId])
@@index([projectId])
}
model OrderItem {
id String @id @default(uuid()) @db.Uuid
orderId String @db.Uuid
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
position Int
name String // отображаемое название позиции
serviceId String? @db.Uuid // привязка к каталогу, если совпало
service ServiceCatalog? @relation(fields: [serviceId], references: [id])
qtyMilli BigInt @default(1000)
unit String @default("шт")
priceCents BigInt
vat VatRate @default(none)
sumCents BigInt
eventDate DateTime? // для услуг с привязкой к дате мероприятия (голосование 14 мая и т.д.)
@@index([orderId])
@@index([serviceId])
}
model Project {
@@ -110,6 +194,7 @@ model Project {
updatedAt DateTime @updatedAt
documents Document[]
orders Order[]
@@index([organizationId, archivedAt])
@@index([organizationId, status])
@@ -173,6 +258,7 @@ model ServiceCatalog {
updatedAt DateTime @updatedAt
lines DocumentLine[]
orderItems OrderItem[]
@@index([organizationId])
@@index([organizationId, archivedAt])
@@ -189,6 +275,7 @@ model DocumentTemplate {
updatedAt DateTime @updatedAt
defaultForProjects Project[]
defaultForSites Site[] @relation("OfferTemplate")
@@index([organizationId, docType])
}
+259
View File
@@ -0,0 +1,259 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalText } from '../../lib/zod-utils.js';
const STATUSES = ['new', 'accepted', 'invoiced', 'paid', 'fulfilled', 'cancelled'] as const;
const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const;
// Внешний (S2S) payload от сайта — толерантный
const IncomingItem = z.object({
name: z.string().min(1).max(500),
qty: z.coerce.number().positive().default(1),
unit: z.string().max(50).default('шт'),
priceRub: z.coerce.number().nonnegative(), // в рублях, чтобы сайту удобней
vat: z.enum(VAT_VALUES).default('none'),
eventDate: z.string().datetime().nullable().optional(),
serviceSlug: z.string().optional(), // если сайт знает наш каталог
});
const IncomingOrder = z.object({
customerName: z.string().min(1).max(500),
customerEmail: optionalText(200),
customerPhone: optionalText(50),
customerInn: optionalText(20),
customerKpp: optionalText(20),
customerAddress: optionalText(1000),
customerKind: z.enum(['ul', 'ip', 'fl']).default('ul'),
notes: optionalText(2000),
acceptedOfferAt: z.string().datetime().nullable().optional(),
items: z.array(IncomingItem).min(1),
});
const ListQuery = z.object({
status: z.enum(STATUSES).optional(),
siteId: z.string().uuid().optional(),
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
});
function sumLines(items: { qty: number; priceRub: number; vat: (typeof VAT_VALUES)[number] }[]): {
totalCents: bigint;
vatCents: bigint;
} {
let total = 0n;
let vat = 0n;
for (const i of items) {
const lineSum = BigInt(Math.round(i.qty * i.priceRub * 100));
total += lineSum;
if (i.vat !== 'none') {
const rate = Number(i.vat.replace('vat_', ''));
vat += (lineSum * BigInt(rate)) / BigInt(100 + rate);
}
}
return { totalCents: total, vatCents: vat };
}
export async function ordersRoutes(app: FastifyInstance) {
// ---- list ----
app.get('/api/orders', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { status, siteId, q, limit } = parsed.data;
const items = await prisma.order.findMany({
where: {
organizationId: orgId,
archivedAt: null,
...(status ? { status } : {}),
...(siteId ? { siteId } : {}),
...(q ? { customerName: { contains: q, mode: 'insensitive' as const } } : {}),
},
include: {
site: { select: { id: true, name: true, slug: true } },
project: { select: { id: true, name: true } },
_count: { select: { items: true } },
},
orderBy: { createdAt: 'desc' },
take: limit,
});
return { items };
});
app.get('/api/orders/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const order = await prisma.order.findFirst({
where: { id, organizationId: orgId },
include: {
site: true,
project: { select: { id: true, name: true, status: true } },
items: { orderBy: { position: 'asc' } },
},
});
if (!order) {
reply.code(404).send({ error: 'not_found' });
return;
}
return order;
});
// ---- S2S incoming ----
// Сайт отправляет POST с заголовком X-Site-Key. ApiKey однозначно определяет site → organizationId.
app.post('/api/incoming/orders', async (req, reply) => {
const apiKey = (req.headers['x-site-key'] || req.headers['X-Site-Key']) as string | undefined;
if (!apiKey || typeof apiKey !== 'string' || apiKey.length < 10) {
reply.code(401).send({ error: 'missing_api_key' });
return;
}
const site = await prisma.site.findFirst({ where: { apiKey, archivedAt: null } });
if (!site) {
reply.code(401).send({ error: 'invalid_api_key' });
return;
}
const parsed = IncomingOrder.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const data = parsed.data;
const sums = sumLines(data.items);
// Попытка матчинга services по slug (необяз., если сайт знает каталог)
const slugs = data.items.map((i) => i.serviceSlug).filter((s): s is string => !!s);
const matched = slugs.length
? await prisma.serviceCatalog.findMany({
where: { organizationId: site.organizationId, name: { in: slugs } },
})
: [];
const matchedByName = new Map(matched.map((s) => [s.name, s.id]));
const created = await prisma.order.create({
data: {
organizationId: site.organizationId,
siteId: site.id,
status: 'new',
customerName: data.customerName,
customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
customerInn: data.customerInn,
customerKpp: data.customerKpp,
customerAddress: data.customerAddress,
customerKind: data.customerKind,
totalCents: sums.totalCents,
vatCents: sums.vatCents,
currency: 'RUB',
acceptedOfferAt: data.acceptedOfferAt ? new Date(data.acceptedOfferAt) : null,
notes: data.notes,
rawPayload: req.body as Prisma.InputJsonValue,
items: {
create: data.items.map((it, i) => {
const priceCents = BigInt(Math.round(it.priceRub * 100));
const qtyMilli = BigInt(Math.round(it.qty * 1000));
const sumCents = (priceCents * qtyMilli) / 1000n;
return {
position: i,
name: it.name,
serviceId: it.serviceSlug ? matchedByName.get(it.serviceSlug) ?? null : null,
qtyMilli,
unit: it.unit,
priceCents,
vat: it.vat,
sumCents,
eventDate: it.eventDate ? new Date(it.eventDate) : null,
};
}),
},
},
include: { items: { orderBy: { position: 'asc' } } },
});
reply.code(201).send(created);
});
// ---- convert order → project ----
app.post(
'/api/orders/:id/convert-to-project',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const order = await prisma.order.findFirst({
where: { id, organizationId: orgId },
include: { items: { orderBy: { position: 'asc' } }, site: true },
});
if (!order) {
reply.code(404).send({ error: 'not_found' });
return;
}
if (order.projectId) {
reply.code(409).send({ error: 'already_converted', projectId: order.projectId });
return;
}
// 1. Найти/создать клиента
let clientId: string | null = null;
if (order.customerInn) {
const existing = await prisma.client.findFirst({
where: { organizationId: orgId, inn: order.customerInn },
});
if (existing) clientId = existing.id;
}
if (!clientId) {
const client = await prisma.client.create({
data: {
organizationId: orgId,
kind: order.customerKind,
name: order.customerName,
inn: order.customerInn,
kpp: order.customerKpp,
address: order.customerAddress,
email: order.customerEmail,
phone: order.customerPhone,
},
});
clientId = client.id;
}
// 2. Создать project
const projectName = order.site
? `${order.site.name}${order.customerName} (${new Date(order.createdAt).toLocaleDateString('ru-RU')})`
: `Заявка — ${order.customerName}`;
const project = await prisma.project.create({
data: {
organizationId: orgId,
name: projectName,
status: 'active',
defaultClientId: clientId,
defaultTemplateId: null,
defaultBankAccountId: null,
notes: order.notes,
},
});
// 3. Связать order ← project
await prisma.order.update({
where: { id: order.id },
data: { projectId: project.id, status: order.status === 'new' ? 'accepted' : order.status },
});
return { project, clientId };
},
);
app.delete('/api/orders/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.order.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.order.update({ where: { id }, data: { archivedAt: new Date() } });
reply.code(204).send();
});
}
+110
View File
@@ -0,0 +1,110 @@
import { randomBytes } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalText } from '../../lib/zod-utils.js';
const SiteUpsert = z.object({
name: z.string().min(1).max(200),
slug: z.string().min(1).max(50).regex(/^[a-z0-9_-]+$/, 'только латиница, цифры, _ и -'),
domain: optionalText(200),
defaultOfferTemplateId: z.string().uuid().nullable(),
});
const SITE_SAFE = {
id: true, organizationId: true, name: true, slug: true, domain: true,
defaultOfferTemplateId: true, archivedAt: true, createdAt: true, updatedAt: true,
} as const;
export async function sitesRoutes(app: FastifyInstance) {
app.get('/api/sites', { preHandler: app.requireDocPermission('viewer') }, async (req) => {
const orgId = getOrganizationId(req);
const items = await prisma.site.findMany({
where: { organizationId: orgId, archivedAt: null },
select: SITE_SAFE,
orderBy: { createdAt: 'asc' },
});
return { items };
});
// ВКЛЮЧАЕТ apiKey — возвращается ТОЛЬКО владельцу через эту ручку
app.get('/api/sites/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const site = await prisma.site.findFirst({
where: { id, organizationId: orgId, archivedAt: null },
});
if (!site) {
reply.code(404).send({ error: 'not_found' });
return;
}
return site;
});
app.post('/api/sites', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = SiteUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const apiKey = randomBytes(24).toString('hex');
try {
const created = await prisma.site.create({
data: { ...parsed.data, apiKey, organizationId: orgId },
});
return created;
} catch (e) {
if ((e as { code?: string }).code === 'P2002') {
reply.code(409).send({ error: 'slug_taken', message: 'Slug уже занят в этой компании' });
return;
}
throw e;
}
});
app.put('/api/sites/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = SiteUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.site.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.site.update({ where: { id }, data: parsed.data, select: SITE_SAFE });
});
app.post(
'/api/sites/:id/regenerate-key',
{ preHandler: app.requireDocPermission('admin') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.site.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
const apiKey = randomBytes(24).toString('hex');
return prisma.site.update({ where: { id }, data: { apiKey } });
},
);
app.delete('/api/sites/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.site.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.site.update({ where: { id }, data: { archivedAt: new Date() } });
reply.code(204).send();
});
}
@@ -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' };
}
});
}
+6
View File
@@ -19,6 +19,9 @@ import { templatesImportRoutes } from './modules/templates/import.routes.js';
import { projectsRoutes } from './modules/projects/routes.js';
import { tochkaRoutes } from './modules/tochka/routes.js';
import { tochkaIssueRoutes } from './modules/tochka/issue.routes.js';
import { tochkaWebhookRoutes } from './modules/tochka/webhook.routes.js';
import { sitesRoutes } from './modules/sites/routes.js';
import { ordersRoutes } from './modules/orders/routes.js';
import { dadataRoutes } from './modules/dadata/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js';
import activeOrgPlugin from './plugins/activeOrg.js';
@@ -60,6 +63,9 @@ async function main() {
await app.register(projectsRoutes);
await app.register(tochkaRoutes);
await app.register(tochkaIssueRoutes);
await app.register(tochkaWebhookRoutes);
await app.register(sitesRoutes);
await app.register(ordersRoutes);
await app.register(dadataRoutes);
app.addHook('onClose', async () => {