From c8f0306abb0c2d0a7bfb79716b961bce1d6a1075 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 14:29:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(M4):=20Tochka=20bank=20integration=20?= =?UTF-8?q?=E2=80=94=20credentials=20+=20issue=20invoice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/lib/crypto.ts | 38 ++++ apps/api/src/modules/tochka/client.ts | 137 ++++++++++++ apps/api/src/modules/tochka/issue.routes.ts | 178 +++++++++++++++ apps/api/src/modules/tochka/routes.ts | 132 +++++++++++ apps/api/src/server.ts | 4 + apps/web/src/api.ts | 19 ++ apps/web/src/pages/CompanyEdit.tsx | 236 +++++++++++++++++++- apps/web/src/pages/DocumentEdit.tsx | 78 +++++++ apps/web/src/styles.css | 16 ++ 9 files changed, 833 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/lib/crypto.ts create mode 100644 apps/api/src/modules/tochka/client.ts create mode 100644 apps/api/src/modules/tochka/issue.routes.ts create mode 100644 apps/api/src/modules/tochka/routes.ts diff --git a/apps/api/src/lib/crypto.ts b/apps/api/src/lib/crypto.ts new file mode 100644 index 0000000..a7e3828 --- /dev/null +++ b/apps/api/src/lib/crypto.ts @@ -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'); +} diff --git a/apps/api/src/modules/tochka/client.ts b/apps/api/src/modules/tochka/client.ts new file mode 100644 index 0000000..5ba05e0 --- /dev/null +++ b/apps/api/src/modules/tochka/client.ts @@ -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(method: string, path: string, body?: unknown): Promise { + 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 { + 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 { + 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()); + } +} diff --git a/apps/api/src/modules/tochka/issue.routes.ts b/apps/api/src/modules/tochka/issue.routes.ts new file mode 100644 index 0000000..5252444 --- /dev/null +++ b/apps/api/src/modules/tochka/issue.routes.ts @@ -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 }); + } + }, + ); +} diff --git a/apps/api/src/modules/tochka/routes.ts b/apps/api/src/modules/tochka/routes.ts new file mode 100644 index 0000000..ea1760e --- /dev/null +++ b/apps/api/src/modules/tochka/routes.ts @@ -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, + }); + } + }, + ); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index fc707ab..91407c1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 () => { diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a457467..921d67d 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -286,6 +286,25 @@ export type Project = ProjectSummary & { defaultBankAccount: BankAccount | null; }; +export type TochkaEnv = 'sandbox' | 'prod'; + +export type TochkaCredential = { + id: string; + environment: TochkaEnv; + customerCode: string; + accountCode: string | null; + bankAccountId: string | null; + expiresAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type TochkaCustomer = { + customerCode: string; + customerType?: string; + fullName?: string; +}; + export type DadataParty = { inn: string; kpp: string | null; diff --git a/apps/web/src/pages/CompanyEdit.tsx b/apps/web/src/pages/CompanyEdit.tsx index b5085df..c3f063e 100644 --- a/apps/web/src/pages/CompanyEdit.tsx +++ b/apps/web/src/pages/CompanyEdit.tsx @@ -1,6 +1,15 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { api, ApiError, type BankAccount, type DadataParty, type Organization } from '../api.js'; +import { + api, + ApiError, + type BankAccount, + type DadataParty, + type Organization, + type TochkaCredential, + type TochkaCustomer, + type TochkaEnv, +} from '../api.js'; import { Button, EmptyState, Field, Modal } from '../components/ui.js'; import { InnLookupButton } from '../components/InnLookup.js'; @@ -37,7 +46,7 @@ export function CompanyEditPage() { {tab === 'requisites' ? : null} {tab === 'banks' ? : null} - {tab === 'integrations' ? : null} + {tab === 'integrations' ? : null} ); } @@ -277,13 +286,230 @@ function BanksTab({ orgId }: { orgId: string }) { ); } -function IntegrationsTab() { +// =================== Tochka integration =================== + +const ENV_LABEL: Record = { + sandbox: 'Sandbox (тестовый)', + prod: 'Production', +}; + +function IntegrationsTab({ orgId }: { orgId: string }) { + const [items, setItems] = useState(null); + const [editing, setEditing] = useState<{ + environment: TochkaEnv; + jwt: string; + customerCode: string; + accountCode: string; + bankAccountId: string | null; + } | null>(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [testResult, setTestResult] = useState<{ env: TochkaEnv; customers: TochkaCustomer[] } | null>(null); + const [bankAccounts, setBankAccounts] = useState([]); + + async function load() { + try { + const [creds, bas] = await Promise.all([ + api.get<{ items: TochkaCredential[] }>(`/api/organizations/${orgId}/tochka/credentials`), + api.get<{ items: BankAccount[] }>(`/api/organizations/${orgId}/bank-accounts`), + ]); + setItems(creds.items); + setBankAccounts(bas.items); + } catch (e) { + setError(String(e)); + } + } + useEffect(() => { void load(); /* eslint-disable-next-line */ }, [orgId]); + + async function save() { + if (!editing) return; + setSaving(true); + setError(null); + try { + await api.post(`/api/organizations/${orgId}/tochka/credentials`, editing); + setEditing(null); + await load(); + } catch (e) { + setError(e instanceof ApiError ? e.prettyMessage() : String(e)); + } finally { + setSaving(false); + } + } + + async function remove(id: string) { + if (!confirm('Удалить привязку Точки?')) return; + try { + await api.del(`/api/organizations/${orgId}/tochka/credentials/${id}`); + await load(); + } catch (e) { + setError(String(e)); + } + } + + async function testConnection(env: TochkaEnv) { + setTestResult(null); + setError(null); + try { + const r = await api.get<{ items: TochkaCustomer[] }>( + `/api/organizations/${orgId}/tochka/customers?env=${env}`, + ); + setTestResult({ env, customers: r.items }); + } catch (e) { + setError(e instanceof ApiError ? e.prettyMessage() : String(e)); + } + } + + const usedEnvs = new Set((items ?? []).map((c) => c.environment)); + const canAdd: TochkaEnv[] = (['sandbox', 'prod'] as const).filter((e) => !usedEnvs.has(e)); + return (
-

Интеграции

+

Точка-банк

- В разработке (этап M4): подключение API банка Точка для выставления счетов и приёма webhook-ов о платежах. + JWT-токен из личного кабинета банка. Шифруется на сервере (AES-256-GCM), не передаётся обратно в UI. + Для теста используй sandbox с токеном sandbox.jwt.token и любым customerCode/accountCode.

+ +
+

Привязки

+ {canAdd.length > 0 ? ( +
+ {canAdd.map((e) => ( + + ))} +
+ ) : null} +
+ + {error ?
{error}
: null} + + {items === null ? ( +

Загрузка…

+ ) : items.length === 0 ? ( + Точка ещё не подключена. Добавьте sandbox для теста или production для боевого выставления счетов. + ) : ( + + + + + + + + + + + + {items.map((c) => ( + + + + + + + + + ))} + +
ОкружениеcustomerCodeaccountIdПривязан к счётуОбновлено +
{ENV_LABEL[c.environment]}{c.customerCode}{c.accountCode ? {c.accountCode} : '—'}{bankAccounts.find((b) => b.id === c.bankAccountId)?.name ?? '—'}{new Date(c.updatedAt).toLocaleDateString('ru-RU')} + + + +
+ )} + + {testResult ? ( +
+ + Соединение с {ENV_LABEL[testResult.env]} работает. Найдено customer'ов: {testResult.customers.length}. + {testResult.customers.length > 0 ? ( + <> Доступные customerCode: {testResult.customers.map((c) => c.customerCode).join(', ')} + ) : null} + +
+ ) : null} + + setEditing(null)} + footer={ + <> + + + + } + > + {editing ? ( +
+ setEditing({ ...editing, jwt: e.target.value })} + placeholder={editing.environment === 'sandbox' ? 'sandbox.jwt.token' : 'eyJhbG…'} + /> + setEditing({ ...editing, customerCode: e.target.value })} + placeholder="304XXXXXX" + /> + setEditing({ ...editing, accountCode: e.target.value })} + placeholder="40702810XXXXXXXXXXXX" + /> + +

+ customerCode и accountId возьмёшь из личного кабинета Точки. Если не помнишь — после сохранения нажми «Проверить» — мы запросим список customer'ов. +

+
+ ) : null} +
); } diff --git a/apps/web/src/pages/DocumentEdit.tsx b/apps/web/src/pages/DocumentEdit.tsx index 0be36ed..5ed9887 100644 --- a/apps/web/src/pages/DocumentEdit.tsx +++ b/apps/web/src/pages/DocumentEdit.tsx @@ -106,6 +106,10 @@ export function DocumentEditPage() { const [body, setBody] = useState(null); const [lines, setLines] = useState([]); const [tochkaLocked, setTochkaLocked] = useState(false); + const [tochkaDocumentId, setTochkaDocumentId] = useState(null); + const [tochkaEnvironment, setTochkaEnvironment] = useState(null); + const [tochkaIssuing, setTochkaIssuing] = useState(false); + const [tochkaPaymentStatus, setTochkaPaymentStatus] = useState(null); const [advancedMode, setAdvancedMode] = useState(false); const [saving, setSaving] = useState(false); @@ -137,6 +141,8 @@ export function DocumentEditPage() { setStatus(d.status); setClientId(d.clientId); setProjectId((d as Document & { projectId?: string | null }).projectId ?? null); + setTochkaDocumentId(d.tochkaDocumentId ?? null); + setTochkaEnvironment((d as Document & { tochkaEnvironment?: string | null }).tochkaEnvironment ?? null); setBody(d.body); setLines(d.lines); setTochkaLocked(!!d.tochkaDocumentId); @@ -231,6 +237,48 @@ export function DocumentEditPage() { } } + async function issueViaTochka() { + if (!savedId) { + setError('Сначала сохраните счёт.'); + return; + } + if (!confirm('Выставить счёт через Точку? После выставления документ станет «только-чтение» в этом интерфейсе.')) { + return; + } + setTochkaIssuing(true); + setError(null); + try { + const r = await api.post<{ tochkaDocumentId: string; environment: string }>( + `/api/documents/${savedId}/issue-tochka`, + {}, + ); + setTochkaDocumentId(r.tochkaDocumentId); + setTochkaEnvironment(r.environment); + setTochkaLocked(true); + setStatus('issued'); + } catch (e) { + if (e instanceof ApiError) { + setError(`${e.code}: ${(e.details as { message?: string })?.message ?? e.prettyMessage()}`); + } else { + setError(String(e)); + } + } finally { + setTochkaIssuing(false); + } + } + + async function checkPaymentStatus() { + if (!savedId) return; + try { + const r = await api.get<{ paymentStatus: string; paidSum?: number }>( + `/api/documents/${savedId}/tochka/status`, + ); + setTochkaPaymentStatus(r.paymentStatus + (r.paidSum != null ? ` (оплачено ${r.paidSum})` : '')); + } catch (e) { + setError(e instanceof ApiError ? e.prettyMessage() : String(e)); + } + } + async function saveAsTemplate() { if (!body) return; const name = prompt('Название шаблона?', `Шаблон ${DOC_TYPE_LABEL[docType].toLowerCase()}`); @@ -331,6 +379,36 @@ export function DocumentEditPage() { ) : null} + {docType === 'invoice' ? ( +
+ {tochkaDocumentId ? ( + <> +
+ ✅ Выставлен через банк ({tochkaEnvironment === 'sandbox' ? 'sandbox' : 'production'}).
+ Tochka document ID: {tochkaDocumentId} +
+
+ + + {tochkaPaymentStatus ? → {tochkaPaymentStatus} : null} +
+ + ) : ( + <> +
Счёт ещё не выставлен через банк.
+
+ + Точка должна быть подключена в карточке компании → Интеграции. +
+ + )} +
+ ) : null} + {optionalBlocks.length > 0 ? ( diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7a01693..40a1e86 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -319,6 +319,22 @@ body { .inn-lookup { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; } .inn-lookup__error { font-size: 12px; color: #c0392b; } +/* === tochka invoice panel === */ +.tochka-panel { + margin: 16px 0; + padding: 12px 16px; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 8px; +} +.tochka-panel code { + background: rgba(0,0,0,0.05); padding: 1px 6px; border-radius: 3px; font-size: 12px; +} +@media (prefers-color-scheme: dark) { + .tochka-panel { background: #14213d; border-color: #1e3a8a; } + .tochka-panel code { background: rgba(255,255,255,0.08); } +} + /* === document status pills === */ .status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;