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:
@@ -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;
|
||||
|
||||
@@ -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' ? <RequisitesTab org={org} onChange={setOrg} /> : null}
|
||||
{tab === 'banks' ? <BanksTab orgId={org.id} /> : null}
|
||||
{tab === 'integrations' ? <IntegrationsTab /> : null}
|
||||
{tab === 'integrations' ? <IntegrationsTab orgId={org.id} /> : null}
|
||||
</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 (
|
||||
<section>
|
||||
<h3>Интеграции</h3>
|
||||
<h3>Точка-банк</h3>
|
||||
<p className="hint">
|
||||
В разработке (этап M4): подключение API банка Точка для выставления счетов и приёма webhook-ов о платежах.
|
||||
JWT-токен из личного кабинета банка. Шифруется на сервере (AES-256-GCM), не передаётся обратно в UI.
|
||||
Для теста используй sandbox с токеном <code>sandbox.jwt.token</code> и любым customerCode/accountCode.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ export function DocumentEditPage() {
|
||||
const [body, setBody] = useState<DocBody | null>(null);
|
||||
const [lines, setLines] = useState<LineDraft[]>([]);
|
||||
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 [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}
|
||||
</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} />
|
||||
|
||||
{optionalBlocks.length > 0 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user