feat(M4): Tochka bank integration — credentials + issue invoice

Backend:
- lib/crypto.ts — AES-256-GCM encrypt/decrypt for secret storage (TOCHKA_JWT_KEY)
- modules/tochka/client.ts — typed HTTP client with sandbox/prod baseURL,
  auto Bearer auth from decrypted JWT, 30s timeout
  endpoints: getCustomers, getAccounts, createInvoice, getInvoicePaymentStatus, getInvoicePdf
- modules/tochka/routes.ts — credentials CRUD + GET test-connection (lists customers)
  JWT never returned in responses
- modules/tochka/issue.routes.ts:
  - POST /api/documents/:id/issue-tochka — creates invoice in Tochka, saves
    documentId+environment, advances status draft→issued
  - GET /api/documents/:id/tochka/status — payment status check
  - GET /api/documents/:id/tochka/pdf — proxy bank's PDF
  Selects credential prod-first, falls back to sandbox

Frontend:
- api.ts: TochkaEnv, TochkaCredential, TochkaCustomer types
- CompanyEdit > Integrations tab: full UI — list creds, add for sandbox/prod,
  «Проверить» button calls test-connection (validates JWT works), update token
  / archive, paste-friendly defaults (sandbox.jwt.token preset for sandbox)
- DocumentEdit (when docType=invoice): tochka-panel
  - if not issued: «🏦 Выставить через Точку» button
  - if issued: shows env+documentId, «PDF из банка» and «Статус оплаты» buttons

Sandbox flow: create sandbox credential with token «sandbox.jwt.token» and
any customerCode/accountCode → test connection → issue invoice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 14:29:37 +03:00
parent f95fa7c254
commit c8f0306abb
9 changed files with 833 additions and 5 deletions
+38
View File
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { env } from '../env.js';
// AES-256-GCM шифрование секретов в БД (JWT-токенов API банка).
// Формат хранения: base64( iv[12] | tag[16] | ct ).
const ALGO = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;
function getKey(): Buffer {
if (!env.TOCHKA_JWT_KEY) {
throw Object.assign(new Error('TOCHKA_JWT_KEY не задан в env'), { code: 'NO_ENCRYPTION_KEY' });
}
const key = Buffer.from(env.TOCHKA_JWT_KEY, 'base64');
if (key.length !== 32) {
throw new Error(`TOCHKA_JWT_KEY должен быть 32 байта в base64 (получено ${key.length})`);
}
return key;
}
export function encryptSecret(plain: string): string {
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, getKey(), iv);
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ct]).toString('base64');
}
export function decryptSecret(encoded: string): string {
const buf = Buffer.from(encoded, 'base64');
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ct = buf.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, getKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
+137
View File
@@ -0,0 +1,137 @@
import type { TochkaCredential } from '@prisma/client';
import { decryptSecret } from '../../lib/crypto.js';
const BASE_URLS: Record<'sandbox' | 'prod', string> = {
sandbox: 'https://enter.tochka.com/sandbox/v2',
prod: 'https://enter.tochka.com/uapi',
};
export type TochkaCustomer = {
customerCode: string;
customerType?: string;
fullName?: string;
};
export type TochkaInvoiceItem = {
name: string;
measure: string; // ед. измерения, напр. "шт"
amount: number; // количество (>0), может быть дробным
price: number; // цена за единицу В РУБЛЯХ (не копейки)
vatRate: 'none' | '0' | '5' | '7' | '10' | '20';
};
export type TochkaSecondSide = {
inn: string;
kpp?: string | undefined;
legalAddress?: string | undefined;
};
export type TochkaCreateInvoiceRequest = {
Data: {
accountId: string;
customerCode: string;
documentNumber?: string | undefined;
documentDate?: string | undefined;
secondSide: TochkaSecondSide;
items: TochkaInvoiceItem[];
paymentExpiryDate?: string | undefined;
};
};
export type TochkaError = Error & {
code: 'TOCHKA_HTTP_ERROR' | 'TOCHKA_TIMEOUT';
status?: number;
body?: string;
};
export class TochkaClient {
constructor(private cred: TochkaCredential) {}
private get baseUrl(): string {
return BASE_URLS[this.cred.environment];
}
private get token(): string {
return decryptSecret(this.cred.jwtEncrypted);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
let res: Response;
const init: RequestInit = {
method,
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
signal: AbortSignal.timeout(30_000),
};
if (body !== undefined) init.body = JSON.stringify(body);
try {
res = await fetch(url, init);
} catch (e) {
throw Object.assign(new Error(`Tochka unreachable: ${(e as Error).message}`), { code: 'TOCHKA_TIMEOUT' });
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw Object.assign(new Error(`Tochka ${res.status}: ${text.slice(0, 500)}`), {
code: 'TOCHKA_HTTP_ERROR',
status: res.status,
body: text,
});
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// GET /open-banking/v1.0/customers
async getCustomers(): Promise<TochkaCustomer[]> {
const r = await this.request<{ Data: { Customer: TochkaCustomer[] } }>(
'GET',
'/open-banking/v1.0/customers',
);
return r.Data?.Customer ?? [];
}
// GET /open-banking/v1.0/customers/{customerCode}/accounts
async getAccounts(customerCode: string): Promise<{ accountId: string; accountCode?: string; status?: string }[]> {
const r = await this.request<{ Data: { Account: { accountId: string; accountCode?: string; status?: string }[] } }>(
'GET',
`/open-banking/v1.0/customers/${encodeURIComponent(customerCode)}/accounts`,
);
return r.Data?.Account ?? [];
}
// POST /invoice/v1.0/bills
async createInvoice(req: TochkaCreateInvoiceRequest): Promise<{ documentId: string }> {
const r = await this.request<{ Data: { documentId: string } }>('POST', '/invoice/v1.0/bills', req);
return r.Data;
}
// GET /invoice/v1.0/bills/{id}/payment-status
async getInvoicePaymentStatus(documentId: string): Promise<{ paymentStatus: string; paidSum?: number }> {
const r = await this.request<{ Data: { paymentStatus: string; paidSum?: number } }>(
'GET',
`/invoice/v1.0/bills/${encodeURIComponent(documentId)}/payment-status`,
);
return r.Data;
}
// GET /invoice/v1.0/bills/{id}/file — возвращает PDF
async getInvoicePdf(documentId: string): Promise<Buffer> {
const url = `${this.baseUrl}/invoice/v1.0/bills/${encodeURIComponent(documentId)}/file`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw Object.assign(new Error(`Tochka PDF ${res.status}: ${text.slice(0, 200)}`), {
code: 'TOCHKA_HTTP_ERROR',
status: res.status,
});
}
return Buffer.from(await res.arrayBuffer());
}
}
+178
View File
@@ -0,0 +1,178 @@
import type { FastifyInstance } from 'fastify';
import type { VatRate } from '@prisma/client';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { TochkaClient } from './client.js';
function mapVat(vat: VatRate): 'none' | '0' | '5' | '7' | '10' | '20' {
if (vat === 'none') return 'none';
return vat.replace('vat_', '') as '0' | '5' | '7' | '10' | '20';
}
export async function tochkaIssueRoutes(app: FastifyInstance) {
// Выставить счёт через Точку для существующего документа docType=invoice
app.post(
'/api/documents/:id/issue-tochka',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const doc = await prisma.document.findFirst({
where: { id, organizationId: orgId },
include: { client: true, lines: { orderBy: { position: 'asc' } } },
});
if (!doc || doc.docType !== 'invoice') {
reply.code(404).send({ error: 'invoice_not_found' });
return;
}
if (doc.tochkaDocumentId) {
reply.code(409).send({
error: 'already_issued',
tochkaDocumentId: doc.tochkaDocumentId,
environment: doc.tochkaEnvironment,
});
return;
}
if (!doc.client?.inn) {
reply.code(422).send({ error: 'no_customer_inn', message: 'У клиента не указан ИНН.' });
return;
}
if (doc.lines.length === 0) {
reply.code(422).send({ error: 'no_lines', message: 'В счёте нет строк услуг.' });
return;
}
// Берём prod если есть, иначе sandbox.
const cred =
(await prisma.tochkaCredential.findFirst({
where: { organizationId: orgId, environment: 'prod' },
})) ??
(await prisma.tochkaCredential.findFirst({
where: { organizationId: orgId, environment: 'sandbox' },
}));
if (!cred) {
reply.code(412).send({
error: 'no_tochka_credentials',
message: 'Точка не подключена. Зайди в карточку компании → вкладка «Интеграции».',
});
return;
}
if (!cred.accountCode) {
reply.code(412).send({ error: 'no_account_code', message: 'В реквизитах Точки не указан accountId.' });
return;
}
const items = doc.lines.map((l) => ({
name: l.name,
measure: l.unit,
amount: Number(l.qtyMilli) / 1000,
price: Number(l.priceCents) / 100,
vatRate: mapVat(l.vat),
}));
try {
const client = new TochkaClient(cred);
const result = await client.createInvoice({
Data: {
accountId: cred.accountCode,
customerCode: cred.customerCode,
documentNumber: doc.number,
documentDate: doc.issuedAt ? doc.issuedAt.toISOString().slice(0, 10) : undefined,
secondSide: {
inn: doc.client.inn,
kpp: doc.client.kpp ?? undefined,
legalAddress: doc.client.address ?? undefined,
},
items,
},
});
const updated = await prisma.document.update({
where: { id },
data: {
tochkaDocumentId: result.documentId,
tochkaEnvironment: cred.environment,
status: doc.status === 'draft' ? 'issued' : doc.status,
},
include: { client: true, lines: { orderBy: { position: 'asc' } } },
});
return {
tochkaDocumentId: result.documentId,
environment: cred.environment,
document: updated,
};
} catch (e) {
const err = e as { code?: string; message?: string; status?: number; body?: string };
app.log.error({ err: e }, 'tochka issue failed');
reply.code(502).send({
error: 'tochka_create_failed',
message: err.message ?? 'unknown',
status: err.status,
body: err.body?.slice(0, 500),
});
}
},
);
// Проверить статус оплаты счёта (вручную, до того как webhook сделаем)
app.get(
'/api/documents/:id/tochka/status',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const doc = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
if (!doc || !doc.tochkaDocumentId) {
reply.code(404).send({ error: 'not_issued_to_tochka' });
return;
}
const cred = await prisma.tochkaCredential.findFirst({
where: { organizationId: orgId, environment: doc.tochkaEnvironment! },
});
if (!cred) {
reply.code(412).send({ error: 'no_tochka_credentials' });
return;
}
try {
const client = new TochkaClient(cred);
return await client.getInvoicePaymentStatus(doc.tochkaDocumentId);
} catch (e) {
const err = e as { message?: string; status?: number };
reply.code(502).send({ error: 'tochka_status_failed', message: err.message, status: err.status });
}
},
);
// Скачать PDF счёта прямо от Точки
app.get(
'/api/documents/:id/tochka/pdf',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const doc = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
if (!doc || !doc.tochkaDocumentId) {
reply.code(404).send({ error: 'not_issued_to_tochka' });
return;
}
const cred = await prisma.tochkaCredential.findFirst({
where: { organizationId: orgId, environment: doc.tochkaEnvironment! },
});
if (!cred) {
reply.code(412).send({ error: 'no_tochka_credentials' });
return;
}
try {
const client = new TochkaClient(cred);
const pdf = await client.getInvoicePdf(doc.tochkaDocumentId);
reply
.type('application/pdf')
.header('Content-Disposition', `inline; filename="tochka-${encodeURIComponent(doc.number)}.pdf"`)
.send(pdf);
} catch (e) {
const err = e as { message?: string; status?: number };
reply.code(502).send({ error: 'tochka_pdf_failed', message: err.message, status: err.status });
}
},
);
}
+132
View File
@@ -0,0 +1,132 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { encryptSecret } from '../../lib/crypto.js';
import { optionalText } from '../../lib/zod-utils.js';
import { TochkaClient } from './client.js';
const ENV = ['sandbox', 'prod'] as const;
const CredentialUpsert = z.object({
environment: z.enum(ENV),
jwt: z.string().min(5),
customerCode: z.string().min(1).max(100),
accountCode: optionalText(100),
bankAccountId: z.string().uuid().nullable(),
});
// Возвращаемая сущность БЕЗ jwtEncrypted (никогда не отдаём наружу)
const CRED_SAFE = {
id: true,
environment: true,
customerCode: true,
accountCode: true,
bankAccountId: true,
expiresAt: true,
createdAt: true,
updatedAt: true,
} as const;
export async function tochkaRoutes(app: FastifyInstance) {
// ---- list ----
app.get(
'/api/organizations/:orgId/tochka/credentials',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const { orgId } = req.params as { orgId: string };
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
const items = await prisma.tochkaCredential.findMany({
where: { organizationId: orgId },
select: CRED_SAFE,
orderBy: { environment: 'asc' },
});
return { items };
},
);
// ---- upsert (один на (org, env)) ----
app.post(
'/api/organizations/:orgId/tochka/credentials',
{ preHandler: app.requireDocPermission('admin') },
async (req, reply) => {
const { orgId } = req.params as { orgId: string };
const parsed = CredentialUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { environment, jwt, customerCode, accountCode, bankAccountId } = parsed.data;
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
let jwtEncrypted: string;
try {
jwtEncrypted = encryptSecret(jwt);
} catch (e) {
if ((e as { code?: string }).code === 'NO_ENCRYPTION_KEY') {
reply.code(503).send({ error: 'no_encryption_key', message: 'TOCHKA_JWT_KEY не задан на сервере' });
return;
}
throw e;
}
const result = await prisma.tochkaCredential.upsert({
where: { organizationId_environment: { organizationId: orgId, environment } },
create: { organizationId: orgId, environment, jwtEncrypted, customerCode, accountCode, bankAccountId },
update: { jwtEncrypted, customerCode, accountCode, bankAccountId },
select: CRED_SAFE,
});
return result;
},
);
// ---- delete ----
app.delete(
'/api/organizations/:orgId/tochka/credentials/:id',
{ preHandler: app.requireDocPermission('admin') },
async (req, reply) => {
const { orgId, id } = req.params as { orgId: string; id: string };
const existing = await prisma.tochkaCredential.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.tochkaCredential.delete({ where: { id } });
reply.code(204).send();
},
);
// ---- test-connection: список customer'ов ----
app.get(
'/api/organizations/:orgId/tochka/customers',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const { orgId } = req.params as { orgId: string };
const environment = (req.query as { env?: string }).env ?? 'sandbox';
const cred = await prisma.tochkaCredential.findFirst({
where: { organizationId: orgId, environment: environment as 'sandbox' | 'prod' },
});
if (!cred) {
reply.code(404).send({ error: 'no_credentials' });
return;
}
try {
const client = new TochkaClient(cred);
const customers = await client.getCustomers();
return { items: customers };
} catch (e) {
const err = e as { code?: string; status?: number; message?: string };
reply.code(502).send({
error: 'tochka_error',
message: err.message ?? 'unknown',
status: err.status,
});
}
},
);
}
+4
View File
@@ -17,6 +17,8 @@ import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
import { templatesRoutes } from './modules/templates/routes.js';
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 { dadataRoutes } from './modules/dadata/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js';
import activeOrgPlugin from './plugins/activeOrg.js';
@@ -56,6 +58,8 @@ async function main() {
await app.register(templatesRoutes);
await app.register(templatesImportRoutes);
await app.register(projectsRoutes);
await app.register(tochkaRoutes);
await app.register(tochkaIssueRoutes);
await app.register(dadataRoutes);
app.addHook('onClose', async () => {