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:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user