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 cancelled
} }
enum OrderStatus {
new // только пришла с сайта
accepted // менеджер принял (или клиент акцептовал оферту)
invoiced // выставлен счёт
paid // оплачено
fulfilled // услуга оказана, документы закрыты
cancelled
}
model Organization { model Organization {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
@@ -89,6 +98,81 @@ model Organization {
auditLog AuditLog[] auditLog AuditLog[]
bankAccounts BankAccount[] bankAccounts BankAccount[]
projects Project[] 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 { model Project {
@@ -110,6 +194,7 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
documents Document[] documents Document[]
orders Order[]
@@index([organizationId, archivedAt]) @@index([organizationId, archivedAt])
@@index([organizationId, status]) @@index([organizationId, status])
@@ -173,6 +258,7 @@ model ServiceCatalog {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
lines DocumentLine[] lines DocumentLine[]
orderItems OrderItem[]
@@index([organizationId]) @@index([organizationId])
@@index([organizationId, archivedAt]) @@index([organizationId, archivedAt])
@@ -189,6 +275,7 @@ model DocumentTemplate {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
defaultForProjects Project[] defaultForProjects Project[]
defaultForSites Site[] @relation("OfferTemplate")
@@index([organizationId, docType]) @@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 { projectsRoutes } from './modules/projects/routes.js';
import { tochkaRoutes } from './modules/tochka/routes.js'; import { tochkaRoutes } from './modules/tochka/routes.js';
import { tochkaIssueRoutes } from './modules/tochka/issue.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 { dadataRoutes } from './modules/dadata/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js'; import { shutdownBrowser } from './modules/documents/pdf.js';
import activeOrgPlugin from './plugins/activeOrg.js'; import activeOrgPlugin from './plugins/activeOrg.js';
@@ -60,6 +63,9 @@ async function main() {
await app.register(projectsRoutes); await app.register(projectsRoutes);
await app.register(tochkaRoutes); await app.register(tochkaRoutes);
await app.register(tochkaIssueRoutes); await app.register(tochkaIssueRoutes);
await app.register(tochkaWebhookRoutes);
await app.register(sitesRoutes);
await app.register(ordersRoutes);
await app.register(dadataRoutes); await app.register(dadataRoutes);
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+7
View File
@@ -10,6 +10,8 @@ import { TemplatesPage } from './pages/Templates.js';
import { TemplateEditPage } from './pages/TemplateEdit.js'; import { TemplateEditPage } from './pages/TemplateEdit.js';
import { ProjectsPage } from './pages/Projects.js'; import { ProjectsPage } from './pages/Projects.js';
import { ProjectEditPage } from './pages/ProjectEdit.js'; import { ProjectEditPage } from './pages/ProjectEdit.js';
import { OrdersPage, OrderViewPage } from './pages/Orders.js';
import { SitesPage } from './pages/Sites.js';
import { OrgSwitcher } from './components/OrgSwitcher.js'; import { OrgSwitcher } from './components/OrgSwitcher.js';
function displayName(me: Me): string { function displayName(me: Me): string {
@@ -22,10 +24,12 @@ function Layout({ me }: { me: Me }) {
<h1>Doc_manager</h1> <h1>Doc_manager</h1>
<nav> <nav>
<Link to="/projects">Проекты</Link> <Link to="/projects">Проекты</Link>
<Link to="/orders">Заявки</Link>
<Link to="/">Документы</Link> <Link to="/">Документы</Link>
<Link to="/clients">Клиенты</Link> <Link to="/clients">Клиенты</Link>
<Link to="/services">Услуги</Link> <Link to="/services">Услуги</Link>
<Link to="/templates">Шаблоны</Link> <Link to="/templates">Шаблоны</Link>
<Link to="/sites">Сайты</Link>
<Link to="/companies">Компании</Link> <Link to="/companies">Компании</Link>
</nav> </nav>
<OrgSwitcher /> <OrgSwitcher />
@@ -136,6 +140,9 @@ export function App() {
<Route path="/companies/:id" element={<CompanyEditPage />} /> <Route path="/companies/:id" element={<CompanyEditPage />} />
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:id" element={<ProjectEditPage />} /> <Route path="/projects/:id" element={<ProjectEditPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/orders/:id" element={<OrderViewPage />} />
<Route path="/sites" element={<SitesPage />} />
<Route path="*" element={<Placeholder title="Не найдено" />} /> <Route path="*" element={<Placeholder title="Не найдено" />} />
</Routes> </Routes>
</> </>
+59
View File
@@ -287,6 +287,65 @@ export type Project = ProjectSummary & {
defaultBankAccount: BankAccount | null; defaultBankAccount: BankAccount | null;
}; };
export type Site = {
id: string;
organizationId: string;
name: string;
slug: string;
domain: string | null;
apiKey?: string; // только в детальном GET (admin)
defaultOfferTemplateId: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type OrderStatus = 'new' | 'accepted' | 'invoiced' | 'paid' | 'fulfilled' | 'cancelled';
export type OrderItem = {
id: string;
orderId: string;
position: number;
name: string;
serviceId: string | null;
qtyMilli: number;
unit: string;
priceCents: number;
vat: VatRate;
sumCents: number;
eventDate: string | null;
};
export type OrderSummary = {
id: string;
organizationId: string;
siteId: string | null;
site: { id: string; name: string; slug: string } | null;
projectId: string | null;
project: { id: string; name: string } | null;
status: OrderStatus;
customerName: string;
customerInn: string | null;
customerEmail: string | null;
customerPhone: string | null;
totalCents: number;
vatCents: number;
currency: string;
acceptedOfferAt: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
_count?: { items: number };
};
export type Order = OrderSummary & {
customerKpp: string | null;
customerAddress: string | null;
customerKind: 'ul' | 'ip' | 'fl';
rawPayload: unknown;
items: OrderItem[];
};
export type TochkaEnv = 'sandbox' | 'prod'; export type TochkaEnv = 'sandbox' | 'prod';
export type TochkaCredential = { export type TochkaCredential = {
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api, ApiError, type Order, type OrderStatus, type OrderSummary } from '../api.js';
import { Button, EmptyState, Select, formatRub } from '../components/ui.js';
const STATUS_LABEL: Record<OrderStatus, string> = {
new: 'Новая',
accepted: 'Принята',
invoiced: 'Счёт выставлен',
paid: 'Оплачена',
fulfilled: 'Выполнена',
cancelled: 'Отменена',
};
export function OrdersPage() {
const [items, setItems] = useState<OrderSummary[] | null>(null);
const [status, setStatus] = useState<OrderStatus | ''>('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
async function load() {
try {
const params = new URLSearchParams();
if (status) params.set('status', status);
const r = await api.get<{ items: OrderSummary[] }>(`/api/orders?${params.toString()}`);
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => { void load(); /* eslint-disable-next-line */ }, [status]);
return (
<main className="content">
<header className="page-head">
<h2>Заявки</h2>
</header>
<div className="toolbar">
<Select
label=""
value={status}
onChange={(v) => setStatus(v as OrderStatus | '')}
options={[
{ value: '', label: 'Все статусы' },
{ value: 'new', label: 'Новые' },
{ value: 'accepted', label: 'Принятые' },
{ value: 'invoiced', label: 'Счёт выставлен' },
{ value: 'paid', label: 'Оплачены' },
{ value: 'fulfilled', label: 'Выполнены' },
]}
/>
</div>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? <p className="hint">Загрузка</p>
: items.length === 0 ? (
<EmptyState>
Заявок пока нет. Они появятся когда сайт пришлёт <code>POST /api/incoming/orders</code> с API-ключом.
Настроить сайты в разделе «Сайты».
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Дата</th>
<th>Источник</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Проект</th>
<th>Статус</th>
<th />
</tr>
</thead>
<tbody>
{items.map((o) => (
<tr key={o.id}>
<td>{new Date(o.createdAt).toLocaleDateString('ru-RU')}</td>
<td>{o.site?.name ?? <span className="hint">manual</span>}</td>
<td>
<Link to={`/orders/${o.id}`}>{o.customerName}</Link>
{o.customerInn ? <div className="hint">ИНН {o.customerInn}</div> : null}
</td>
<td className="num">{formatRub(o.totalCents)}</td>
<td>
{o.project ? <Link to={`/projects/${o.project.id}`}>{o.project.name}</Link> : <span className="hint"></span>}
</td>
<td>
<span className={`status status--${o.status === 'new' ? 'issued' : o.status === 'paid' ? 'paid' : o.status === 'cancelled' ? 'cancelled' : 'issued'}`}>
{STATUS_LABEL[o.status]}
</span>
</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => navigate(`/orders/${o.id}`)}>Открыть</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
export function OrderViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [error, setError] = useState<string | null>(null);
const [converting, setConverting] = useState(false);
useEffect(() => {
if (!id) return;
api.get<Order>(`/api/orders/${id}`).then(setOrder).catch((e) => setError(String(e)));
}, [id]);
async function convertToProject() {
if (!id) return;
setConverting(true);
setError(null);
try {
const r = await api.post<{ project: { id: string } }>(`/api/orders/${id}/convert-to-project`, {});
navigate(`/projects/${r.project.id}`);
} catch (e) {
if (e instanceof ApiError && e.code === 'already_converted') {
const projectId = (e.details as { projectId?: string })?.projectId;
if (projectId) navigate(`/projects/${projectId}`);
} else {
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
}
} finally {
setConverting(false);
}
}
if (!order && !error) return <main className="content"><p className="hint">Загрузка</p></main>;
if (error) return <main className="content"><div className="error-text">{error}</div></main>;
if (!order) return null;
return (
<main className="content">
<header className="page-head">
<h2>Заявка {order.site ? `с ${order.site.name}` : 'manual'}</h2>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => navigate('/orders')}> К заявкам</Button>
{order.project ? (
<Button variant="primary" onClick={() => navigate(`/projects/${order.project!.id}`)}>
Открыть проект
</Button>
) : (
<Button variant="primary" onClick={convertToProject} disabled={converting}>
{converting ? 'Конвертирую…' : 'Перевести в проект →'}
</Button>
)}
</div>
</header>
<section className="form-grid">
<Field label="Клиент" value={order.customerName} />
<Field label="ИНН" value={order.customerInn ?? '—'} />
<Field label="Email" value={order.customerEmail ?? '—'} />
<Field label="Телефон" value={order.customerPhone ?? '—'} />
<Field label="Статус" value={STATUS_LABEL[order.status]} />
<Field label="Создано" value={new Date(order.createdAt).toLocaleString('ru-RU')} />
{order.acceptedOfferAt ? (
<Field label="Оферта принята" value={new Date(order.acceptedOfferAt).toLocaleString('ru-RU')} />
) : null}
<Field label="Сумма" value={formatRub(order.totalCents)} />
</section>
<h3 style={{ marginTop: 24 }}>Позиции</h3>
<table className="table">
<thead>
<tr>
<th></th>
<th>Услуга</th>
<th>Дата</th>
<th>Кол-во</th>
<th>Ед.</th>
<th>Цена</th>
<th>НДС</th>
<th className="num">Сумма</th>
</tr>
</thead>
<tbody>
{order.items.map((it) => (
<tr key={it.id}>
<td>{it.position + 1}</td>
<td>{it.name}</td>
<td>{it.eventDate ? new Date(it.eventDate).toLocaleDateString('ru-RU') : '—'}</td>
<td>{(it.qtyMilli / 1000).toFixed(it.qtyMilli % 1000 === 0 ? 0 : 3)}</td>
<td>{it.unit}</td>
<td className="num">{formatRub(it.priceCents)}</td>
<td>{it.vat === 'none' ? 'Без НДС' : it.vat.replace('vat_', '') + '%'}</td>
<td className="num">{formatRub(Number(it.sumCents))}</td>
</tr>
))}
</tbody>
</table>
{order.notes ? <p style={{ marginTop: 16 }}><b>Заметки:</b> {order.notes}</p> : null}
</main>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="field">
<span className="field__label">{label}</span>
<div style={{ padding: '8px 10px', borderRadius: 6, background: 'rgba(127,127,127,0.06)' }}>{value}</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { api, ApiError, type Site } from '../api.js';
import { Button, EmptyState, Field, Modal } from '../components/ui.js';
export function SitesPage() {
const [items, setItems] = useState<Site[] | null>(null);
const [creating, setCreating] = useState<{ name: string; slug: string; domain: string } | null>(null);
const [createdKey, setCreatedKey] = useState<{ siteName: string; apiKey: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
async function load() {
try {
const r = await api.get<{ items: Site[] }>('/api/sites');
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => { void load(); }, []);
async function create() {
if (!creating) return;
setFieldErrors({});
try {
const site = await api.post<Site & { apiKey: string }>('/api/sites', {
name: creating.name,
slug: creating.slug,
domain: creating.domain || null,
defaultOfferTemplateId: null,
});
setCreatedKey({ siteName: site.name, apiKey: site.apiKey });
setCreating(null);
await load();
} catch (e) {
if (e instanceof ApiError) {
const fe = e.fieldErrors();
setFieldErrors(fe);
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
} else {
setError(String(e));
}
}
}
async function regenerateKey(site: Site) {
if (!confirm(`Перевыпустить API-ключ для «${site.name}»? Старый перестанет работать сразу.`)) return;
try {
const updated = await api.post<Site & { apiKey: string }>(`/api/sites/${site.id}/regenerate-key`, {});
setCreatedKey({ siteName: updated.name, apiKey: updated.apiKey });
await load();
} catch (e) {
setError(String(e));
}
}
async function archive(s: Site) {
if (!confirm(`Архивировать сайт «${s.name}»?`)) return;
await api.del(`/api/sites/${s.id}`);
await load();
}
return (
<main className="content">
<header className="page-head">
<h2>Сайты-источники</h2>
<Button variant="primary" onClick={() => setCreating({ name: '', slug: '', domain: '' })}>
+ Добавить
</Button>
</header>
<p className="hint">
Сайты, с которых приходят заявки. Каждый получает свой API-ключ сайт отправляет
<code> POST https://doc.queo.ru/api/incoming/orders</code> с заголовком <code>X-Site-Key</code>.
</p>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? <p className="hint">Загрузка</p>
: items.length === 0 ? (
<EmptyState>Сайтов ещё нет. Добавь первый выдадим API-ключ для S2S.</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Название</th>
<th>slug</th>
<th>Домен</th>
<th>Создан</th>
<th />
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td><code>{s.slug}</code></td>
<td>{s.domain ?? '—'}</td>
<td>{new Date(s.createdAt).toLocaleDateString('ru-RU')}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => regenerateKey(s)}>Перевыпустить ключ</Button>
<Button variant="danger" onClick={() => archive(s)}>В архив</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Modal
open={creating !== null}
title="Новый сайт"
onClose={() => setCreating(null)}
footer={
<>
<Button variant="ghost" onClick={() => setCreating(null)}>Отмена</Button>
<Button variant="primary" onClick={create}>Создать</Button>
</>
}
>
{creating ? (
<div className="form-grid">
<Field
label="Название"
value={creating.name}
onChange={(e) => setCreating({ ...creating, name: e.target.value })}
placeholder="Голосования"
error={fieldErrors.name}
/>
<Field
label="slug"
value={creating.slug}
onChange={(e) => setCreating({ ...creating, slug: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-') })}
placeholder="voting"
error={fieldErrors.slug}
/>
<Field
label="Домен (опц.)"
value={creating.domain}
onChange={(e) => setCreating({ ...creating, domain: e.target.value })}
placeholder="voting.queo.ru"
error={fieldErrors.domain}
/>
</div>
) : null}
</Modal>
<Modal
open={createdKey !== null}
title={createdKey ? `API-ключ для «${createdKey.siteName}»` : ''}
onClose={() => setCreatedKey(null)}
footer={<Button variant="primary" onClick={() => setCreatedKey(null)}>Сохранил, закрыть</Button>}
>
{createdKey ? (
<div>
<p className="hint"> Ключ показывается только сейчас. Сохрани его в безопасном месте иначе придётся перевыпустить.</p>
<pre style={{ background: '#f1f5f9', padding: 12, borderRadius: 6, overflow: 'auto', userSelect: 'all' }}>
{createdKey.apiKey}
</pre>
<p className="hint" style={{ marginTop: 12 }}>Использование на сайте:</p>
<pre style={{ background: '#f1f5f9', padding: 12, borderRadius: 6, overflow: 'auto', fontSize: 12 }}>
{`POST https://doc.queo.ru/api/incoming/orders
X-Site-Key: ${createdKey.apiKey}
Content-Type: application/json
{
"customerName": "ООО ...",
"customerEmail": "ivan@example.com",
"customerInn": "7707083893",
"acceptedOfferAt": "2026-06-16T10:00:00Z",
"items": [
{ "name": "Голосование 14 мая", "qty": 1, "unit": "день", "priceRub": 10000, "vat": "none", "eventDate": "2026-05-14" }
]
}`}
</pre>
</div>
) : null}
</Modal>
</main>
);
}