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:
@@ -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');
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
|
|||||||
import { templatesRoutes } from './modules/templates/routes.js';
|
import { templatesRoutes } from './modules/templates/routes.js';
|
||||||
import { templatesImportRoutes } from './modules/templates/import.routes.js';
|
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 { tochkaIssueRoutes } from './modules/tochka/issue.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';
|
||||||
@@ -56,6 +58,8 @@ async function main() {
|
|||||||
await app.register(templatesRoutes);
|
await app.register(templatesRoutes);
|
||||||
await app.register(templatesImportRoutes);
|
await app.register(templatesImportRoutes);
|
||||||
await app.register(projectsRoutes);
|
await app.register(projectsRoutes);
|
||||||
|
await app.register(tochkaRoutes);
|
||||||
|
await app.register(tochkaIssueRoutes);
|
||||||
await app.register(dadataRoutes);
|
await app.register(dadataRoutes);
|
||||||
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -286,6 +286,25 @@ export type Project = ProjectSummary & {
|
|||||||
defaultBankAccount: BankAccount | null;
|
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 = {
|
export type DadataParty = {
|
||||||
inn: string;
|
inn: string;
|
||||||
kpp: string | null;
|
kpp: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
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 { Button, EmptyState, Field, Modal } from '../components/ui.js';
|
||||||
import { InnLookupButton } from '../components/InnLookup.js';
|
import { InnLookupButton } from '../components/InnLookup.js';
|
||||||
|
|
||||||
@@ -37,7 +46,7 @@ export function CompanyEditPage() {
|
|||||||
|
|
||||||
{tab === 'requisites' ? <RequisitesTab org={org} onChange={setOrg} /> : null}
|
{tab === 'requisites' ? <RequisitesTab org={org} onChange={setOrg} /> : null}
|
||||||
{tab === 'banks' ? <BanksTab orgId={org.id} /> : null}
|
{tab === 'banks' ? <BanksTab orgId={org.id} /> : null}
|
||||||
{tab === 'integrations' ? <IntegrationsTab /> : null}
|
{tab === 'integrations' ? <IntegrationsTab orgId={org.id} /> : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -277,13 +286,230 @@ function BanksTab({ orgId }: { orgId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IntegrationsTab() {
|
// =================== Tochka integration ===================
|
||||||
|
|
||||||
|
const ENV_LABEL: Record<TochkaEnv, string> = {
|
||||||
|
sandbox: 'Sandbox (тестовый)',
|
||||||
|
prod: 'Production',
|
||||||
|
};
|
||||||
|
|
||||||
|
function IntegrationsTab({ orgId }: { orgId: string }) {
|
||||||
|
const [items, setItems] = useState<TochkaCredential[] | null>(null);
|
||||||
|
const [editing, setEditing] = useState<{
|
||||||
|
environment: TochkaEnv;
|
||||||
|
jwt: string;
|
||||||
|
customerCode: string;
|
||||||
|
accountCode: string;
|
||||||
|
bankAccountId: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ env: TochkaEnv; customers: TochkaCustomer[] } | null>(null);
|
||||||
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h3>Интеграции</h3>
|
<h3>Точка-банк</h3>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
В разработке (этап M4): подключение API банка Точка для выставления счетов и приёма webhook-ов о платежах.
|
JWT-токен из личного кабинета банка. Шифруется на сервере (AES-256-GCM), не передаётся обратно в UI.
|
||||||
|
Для теста используй sandbox с токеном <code>sandbox.jwt.token</code> и любым customerCode/accountCode.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<header className="page-head" style={{ marginTop: 16 }}>
|
||||||
|
<h4 style={{ margin: 0 }}>Привязки</h4>
|
||||||
|
{canAdd.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{canAdd.map((e) => (
|
||||||
|
<Button
|
||||||
|
key={e}
|
||||||
|
variant="primary"
|
||||||
|
onClick={() =>
|
||||||
|
setEditing({
|
||||||
|
environment: e,
|
||||||
|
jwt: e === 'sandbox' ? 'sandbox.jwt.token' : '',
|
||||||
|
customerCode: '',
|
||||||
|
accountCode: '',
|
||||||
|
bankAccountId: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ {ENV_LABEL[e]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? <div className="error-text">{error}</div> : null}
|
||||||
|
|
||||||
|
{items === null ? (
|
||||||
|
<p className="hint">Загрузка…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState>Точка ещё не подключена. Добавьте sandbox для теста или production для боевого выставления счетов.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Окружение</th>
|
||||||
|
<th>customerCode</th>
|
||||||
|
<th>accountId</th>
|
||||||
|
<th>Привязан к счёту</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{ENV_LABEL[c.environment]}</td>
|
||||||
|
<td><code>{c.customerCode}</code></td>
|
||||||
|
<td>{c.accountCode ? <code>{c.accountCode}</code> : '—'}</td>
|
||||||
|
<td>{bankAccounts.find((b) => b.id === c.bankAccountId)?.name ?? '—'}</td>
|
||||||
|
<td>{new Date(c.updatedAt).toLocaleDateString('ru-RU')}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<Button variant="ghost" onClick={() => testConnection(c.environment)}>Проверить</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setEditing({
|
||||||
|
environment: c.environment,
|
||||||
|
jwt: '',
|
||||||
|
customerCode: c.customerCode,
|
||||||
|
accountCode: c.accountCode ?? '',
|
||||||
|
bankAccountId: c.bankAccountId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Обновить токен
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => remove(c.id)}>×</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult ? (
|
||||||
|
<div className="import-banner" style={{ marginTop: 12 }}>
|
||||||
|
<span>
|
||||||
|
Соединение с <b>{ENV_LABEL[testResult.env]}</b> работает. Найдено customer'ов: <b>{testResult.customers.length}</b>.
|
||||||
|
{testResult.customers.length > 0 ? (
|
||||||
|
<> Доступные customerCode: {testResult.customers.map((c) => c.customerCode).join(', ')}</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editing !== null}
|
||||||
|
title={editing ? `Привязка ${ENV_LABEL[editing.environment]}` : ''}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(null)}>Отмена</Button>
|
||||||
|
<Button variant="primary" onClick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохраняю…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field
|
||||||
|
label="JWT-токен"
|
||||||
|
value={editing.jwt}
|
||||||
|
onChange={(e) => setEditing({ ...editing, jwt: e.target.value })}
|
||||||
|
placeholder={editing.environment === 'sandbox' ? 'sandbox.jwt.token' : 'eyJhbG…'}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="customerCode"
|
||||||
|
value={editing.customerCode}
|
||||||
|
onChange={(e) => setEditing({ ...editing, customerCode: e.target.value })}
|
||||||
|
placeholder="304XXXXXX"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="accountId (для счетов)"
|
||||||
|
value={editing.accountCode}
|
||||||
|
onChange={(e) => setEditing({ ...editing, accountCode: e.target.value })}
|
||||||
|
placeholder="40702810XXXXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field__label">Привязать к счёту в нашей БД</span>
|
||||||
|
<select
|
||||||
|
className="field__input"
|
||||||
|
value={editing.bankAccountId ?? ''}
|
||||||
|
onChange={(e) => setEditing({ ...editing, bankAccountId: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">— не выбран —</option>
|
||||||
|
{bankAccounts.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="hint" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
customerCode и accountId возьмёшь из личного кабинета Точки. Если не помнишь — после сохранения нажми «Проверить» — мы запросим список customer'ов.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export function DocumentEditPage() {
|
|||||||
const [body, setBody] = useState<DocBody | null>(null);
|
const [body, setBody] = useState<DocBody | null>(null);
|
||||||
const [lines, setLines] = useState<LineDraft[]>([]);
|
const [lines, setLines] = useState<LineDraft[]>([]);
|
||||||
const [tochkaLocked, setTochkaLocked] = useState(false);
|
const [tochkaLocked, setTochkaLocked] = useState(false);
|
||||||
|
const [tochkaDocumentId, setTochkaDocumentId] = useState<string | null>(null);
|
||||||
|
const [tochkaEnvironment, setTochkaEnvironment] = useState<string | null>(null);
|
||||||
|
const [tochkaIssuing, setTochkaIssuing] = useState(false);
|
||||||
|
const [tochkaPaymentStatus, setTochkaPaymentStatus] = useState<string | null>(null);
|
||||||
const [advancedMode, setAdvancedMode] = useState(false);
|
const [advancedMode, setAdvancedMode] = useState(false);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -137,6 +141,8 @@ export function DocumentEditPage() {
|
|||||||
setStatus(d.status);
|
setStatus(d.status);
|
||||||
setClientId(d.clientId);
|
setClientId(d.clientId);
|
||||||
setProjectId((d as Document & { projectId?: string | null }).projectId ?? null);
|
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);
|
setBody(d.body);
|
||||||
setLines(d.lines);
|
setLines(d.lines);
|
||||||
setTochkaLocked(!!d.tochkaDocumentId);
|
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() {
|
async function saveAsTemplate() {
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const name = prompt('Название шаблона?', `Шаблон ${DOC_TYPE_LABEL[docType].toLowerCase()}`);
|
const name = prompt('Название шаблона?', `Шаблон ${DOC_TYPE_LABEL[docType].toLowerCase()}`);
|
||||||
@@ -331,6 +379,36 @@ export function DocumentEditPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{docType === 'invoice' ? (
|
||||||
|
<section className="tochka-panel">
|
||||||
|
{tochkaDocumentId ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
✅ Выставлен через банк ({tochkaEnvironment === 'sandbox' ? 'sandbox' : 'production'}).<br />
|
||||||
|
<span className="hint">Tochka document ID:</span> <code>{tochkaDocumentId}</code>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
<Button onClick={() => savedId && window.open(`/api/documents/${savedId}/tochka/pdf`, '_blank')}>
|
||||||
|
PDF из банка
|
||||||
|
</Button>
|
||||||
|
<Button onClick={checkPaymentStatus}>Статус оплаты</Button>
|
||||||
|
{tochkaPaymentStatus ? <span className="hint">→ {tochkaPaymentStatus}</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>Счёт ещё не выставлен через банк.</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
|
||||||
|
<Button variant="primary" onClick={issueViaTochka} disabled={tochkaIssuing || !savedId}>
|
||||||
|
{tochkaIssuing ? 'Выставляю…' : '🏦 Выставить через Точку'}
|
||||||
|
</Button>
|
||||||
|
<span className="hint">Точка должна быть подключена в карточке компании → Интеграции.</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<LinesEditor lines={lines} onChange={setLines} clientId={clientId} />
|
<LinesEditor lines={lines} onChange={setLines} clientId={clientId} />
|
||||||
|
|
||||||
{optionalBlocks.length > 0 ? (
|
{optionalBlocks.length > 0 ? (
|
||||||
|
|||||||
@@ -319,6 +319,22 @@ body {
|
|||||||
.inn-lookup { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
|
.inn-lookup { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.inn-lookup__error { font-size: 12px; color: #c0392b; }
|
.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 === */
|
/* === document status pills === */
|
||||||
.status {
|
.status {
|
||||||
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
|
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user