Plank help · updated 2026-09-09
Подключение ЭСФ (электронные счета-фактуры)
Подключите казахстанскую систему электронных счетов-фактур (ИС ЭСФ, esf.gov.kz), чтобы ассистент мог показывать и анализировать ваши счета-фактуры и — по вашей команде — выписывать их, подписывая вашим ключом ЭЦП через официальный API. Без NCALayer и без QR на каждую операцию.
Agents: fetch the raw markdown of this page at /ru/help/connecting-esf.md
Подключение ЭСФ (электронные счета-фактуры)
Ваш ассистент может работать напрямую с ИС ЭСФ (esf.gov.kz) — казахстанской системой электронных счетов-фактур — тем же ключом ЭЦП, которым вы обычно входите. Когда она подключена, вы пишете обычными словами («покажи выписанные счета-фактуры за этот квартал», «выведи счета-фактуры от этого поставщика») и получаете готовый результат, не заходя на портал ЭСФ.
Это использует официальный API интеграции ЭСФ — тот же, которым пользуются учётные системы вроде 1С — поэтому подпись ставится программно вашим ключом. Нет всплывающего окна NCALayer и нет QR-кода для каждой операции: эта поэтапная подпись — особенность только веб-портала, а не самой системы.
Что ассистент умеет, когда ЭСФ подключена
- Показывать и анализировать счета-фактуры — выписанные (исходящие) и полученные (входящие) за период, с фильтром по статусу, контрагенту или дате. С выгрузкой в Excel и PDF, как любой файл.
- Сверять — сопоставлять счета-фактуры ЭСФ с вашими собственными данными или данными 1С.
- Выписывать счета-фактуры — вы описываете продажу словами, а ассистент собирает счёт-фактуру, подписывает вашим ключом и отправляет в ЭСФ. Это происходит только когда вы попросите и подтвердите (см. раздел о безопасности).
Готовые отчёты и подтверждения сохраняются файлами в вашем рабочем пространстве.
Что нужно подготовить перед подключением
- Ваш файл ключа ЭЦП — ключ GOST (
GOST….p12), которым вы пользуетесь для ЭСФ — и его PIN. - Ваш пароль на портале ЭСФ — тот, что в паре с ИИН вы задали при регистрации на esf.gov.kz. API нужен он вместе с ключом.
- Ваш ИИН, и ТРН/БИН ИП или компании, если он отличается от ИИН.
Ставить ничего не нужно — ассистент сам запускает официальный инструментарий подписи ЭСФ.
Подключение
Подключение выполняется простым сообщением ассистенту в чате. Передайте ему:
- ваш файл ключа ЭЦП (загрузите его в рабочее пространство),
- его PIN,
- ваш пароль на портале ЭСФ и
- ваш ИИН (и ТРН/БИН, если отличается).
Ассистент надёжно хранит ключ и секреты в защищённых учётных данных рабочего пространства и использует их только для доступа к ЭСФ. Он никогда не записывает их в заметки, журналы или историю чата. Затем он выполняет проверку доступа: открывает сессию в контексте вашего ИП и выгружает свежий список счетов-фактур — и сообщает простыми словами, что подключение исправно.
Как это защищает вас
- Чтение ничего не меняет. Просмотр и анализ счетов-фактур ничего не меняет в ЭСФ.
- Выписка — только по вашей команде. Ассистент готовит счёт-фактуру и показывает её вам; подписывает и отправляет только после вашего подтверждения. Сам он ничего не выписывает.
- На стороне госсистемы не остаётся «черновика». В ЭСФ нет вызова API для сохранения неподписанного черновика — единственная запись это подписанная отправка. Поэтому ассистент держит подготовленную счёт-фактуру на стороне Plank для вашей проверки и подписывает и отправляет только по вашему одобрению; ничего недоделанного на ЭСФ не остаётся.
- Ключ под вашим контролем. Чтобы подписывать от вашего имени, ключ ЭЦП хранится в защищённом виде в вашем рабочем пространстве Plank. Вы можете удалить его в любой момент, чтобы мгновенно перекрыть доступ. Если вы предпочитаете, чтобы ключ никогда не покидал ваш компьютер, скажите ассистенту — он может запускать подпись локально и держать ключ у вас.
Как это устроено — рецепт для ассистента
Следуйте точно. У API ЭСФ есть особенности на уровне отдельных сервисов, из-за которых импровизированная первая попытка проваливается; ниже — те, что подводят чаще всего.
Установка SDK и среды (локально в рабочем пространстве — не в систему). Переиспользуйте уже скачанное. Всё лежит под scripts/esf/. Перед загрузкой проверьте кэш — повторный запуск не должен заново качать ~180 МБ:
- SDK (~180 МБ). Если в
scripts/esf/sdk/уже есть jar-файлы (find scripts/esf/sdk -name '*.jar' | head), загрузку пропустите. Иначе скачайте официальный SDK ЭСФ («комплект разработчика») — публичный, с esf.gov.kz → Для разработчиков → API. Прямая ссылка:https://kgd.gov.kz/sites/default/files/ftpdata/ESF/esf-sdk-290824%20(1).rar(в имени дата, может обновиться — при 404 посмотрите листингhttps://kgd.gov.kz/sites/default/files/ftpdata/ESF/и возьмите свежийesf-sdk-*.rar). - Распаковка
.rar— в песочнице нетunrar. В базовом образе есть библиотекаlibarchive, но нет бинарникаunrar/unar/7z, поэтому «в лоб»unrar xне сработает. Распакуйте черезbsdtar -xf esf-sdk.rar -C scripts/esf/sdk/(libarchive читает RAR) илиpip install --quiet patool && python3 -c "import patoolib; patoolib.extract_archive('esf-sdk.rar', outdir='scripts/esf/sdk')". Получите деревоscripts/esf/sdk/esf-sdk-*/…с ~200 jar — WSDL, XML-шаблоны счетов-фактур, сгенерированные классыru.uss.esf.*иesf_local_server.jar(сервис подписи на чистой Java на GOST-провайдере Kalkan, без нативной.so). Референсныйesf.pyсобирает classpath из всех jar подsdk/. - Java 8 (обычно уже в кэше). SDK использует JAX-WS, удалённый из JDK в 11+. Если
~/.local/jdks/jdk8*уже есть — используйте его. Иначе поставьте tarball Temurin 8 без sudo в~/.local/jdks/(сборка x64 работает под Rosetta на Apple Silicon). Референсныйesf.pyсам находит любой~/.local/jdks/jdk8*; JDK 11/17 падают только из-за отсутствующего JAX-WS.
Конфиг — один настоящий файл, который заполнит пользователь. Создайте scripts/esf/credentials/config.json с именно этими полями (референсный esf.py читает их по именам). Это файл на рабочее пространство, в git не коммитится — впишите реальные значения и никогда не выводите их в чат, логи или log.md:
{
"iin": "",
"tin": "",
"businessProfileType": "ENTREPRENEUR",
"esfPassword": "",
"keyPath": "scripts/esf/credentials/GOST.p12",
"keyPin": "",
"environment": "production"
}
iin— ИИН владельца сертификата ЭЦП; им подписывается тикет авторизации и это логин UsernameToken.tin— субъект, от имени которого работает сессия: ИИН для ИП, БИН компании для ТОО (читается из сертификата).esfPassword— пароль портала ЭСФ (не PIN ключа).keyPath— путь к ключу GOST.p12относительно рабочего пространства;keyPin— его PIN.businessProfileType— одна из точных строк enum ниже (выберите по типу субъекта):
| Значение | Роль в ЭСФ | Для кого |
|---|---|---|
ADMIN_ENTERPRISE | Администратор юридического лица | ТОО / компания (директор/админ) |
USER | Пользователь, приглашённый в предприятие | приглашённый пользователь компании |
ENTREPRENEUR | Индивидуальный предприниматель | ИП (по умолчанию) |
ENTREPRENEUR_USER | Пользователь, работающий в ИП | пользователь при ИП |
INDIVIDUAL | Физическое лицо | физлицо |
LAWYER | ЛЗЧП: Адвокат | адвокат |
BAILIFF | ЛЗЧП: Частный судебный исполнитель | частный судебный исполнитель |
MEDIATOR | ЛЗЧП: Медиатор | медиатор |
Добавьте .gitignore рабочего пространства (см. раздел с референсом), покрывающий scripts/esf/credentials/config.json, *.p12, sdk/ и скомпилированные классы — чтобы ключ, PIN, пароль портала и 180 МБ SDK не попали в git.
Адреса (прод). База https://esf.gov.kz:8443/esf-web/ws; сервисы по …/api1/<Service> (AuthService, SessionService, InvoiceService, UploadInvoiceService).
Аутентификация — ключ GOST‑2015 ТРЕБУЕТ подписанную сессию. Обычный createSession возвращает METHOD_NOT_SUPPORT_GOST_2015. Рабочая последовательность:
AuthService.createAuthTicket(iin, ttlInMinutes)— без WS-Security (это предварительный вызов; добавление security-заголовка вернёт пустой ответ). Возвращает тикет<authSign>.- Подпишите тикет как enveloped XMLDsig ключом —
XMLUtil.createXmlSignature(new SigningEntity(privateKey, [cert]), ticketXml, kalkanProvider)послеKncaXS.loadXMLSecurity(). Этот же вызов на самом деле нужен любому госпорталу РК, который открывает окно NCALayer; в общем виде он описан в Подписи ключом ЭЦП РК. SessionService.createSessionSigned({ tin, businessProfileType, signedAuthTicket, authWithCert: true })— с WS-Security UsernameToken = ваш ИИН + пароль портала ЭСФ. ВозвращаетsessionId. БеритеtinиbusinessProfileTypeизconfig.json(см. таблицу типов профиля выше —ENTREPRENEURдля ИП,ADMIN_ENTERPRISEдля ТОО).InvoiceService.queryInvoice({ sessionId, criteria: { direction, dateFrom, dateTo, pageNum } })— без WS-Security (вызов авторизуется черезsessionIdв теле). Постраничный обход поisLastBlock.direction—OUTBOUND(выписанные) илиINBOUND(полученные).
Подводные камни, на которых падает первая попытка:
- Аутентификация отличается по сервисам. UsernameToken принимает только
SessionService.AuthService,InvoiceServiceиUploadInvoiceService— без него; добавите — получите пустой ответ/EOF, а не ошибку. - Окно запроса ≤ 1 квартала. Диапазон больше ~90 дней падает с «Можно получить список СФ за период не более 1 квартала». Для длинных отчётов идите по кварталам.
- Выписка идёт через
UploadInvoiceService.syncInvoice(методы клиентаcreateSigned/create): соберите XML счёта-фактуры из шаблонов SDK, подпишите каждый ключом (InvoiceSignatureHelper.sign→ отсоединённая base64-подпись GOST поверх канонического тела) и отправьте. Сначала подтвердите у пользователя. - Нет API черновиков. Вызова «сохранить неподписанный черновик» нет —
syncInvoiceсразу регистрирует подписанный фискальный документ. Держите копию для проверки на стороне Plank и отправляйте только после одобрения. - Java 8 не знает
Path.of/Files.readString/List.of/var. Это API из Java 9+; на JDK 8 клиент не компилируется сcannot find symbol. ИспользуйтеPaths.get(...),new String(Files.readAllBytes(p), StandardCharsets.UTF_8)иArrays.asList(...). Референсный клиент написан чисто под Java 8. - Одна открытая сессия на пользователя.
createSessionSignedпри ещё открытой прежней сессии падает сAccessDeniedException: User already has opened session with id …. Сначала закройте зависшую сессию (SessionService.closeSessionBySignedCredentials) — референсный клиент закрывает существующую сессию перед открытием новой и закрывает её на выходе. syncInvoice"accepted" ≠ зарегистрирован — всегда проверяйте. Отправка возвращает{accepted:[{id,…,errors:[]}]}, даже когда ЭСФ затем переводит счёт-фактуру вFAILED. После отправки опросите её (get --id/queryInvoice):status: CREATED— зарегистрирована,status: FAILED— отклонена. При FAILED вызовитеfindInvoiceErrors(errors --id) за причиной. Типичная ошибка с первого раза —SIGNATURE_VERIFICATION_FAILED(«Ошибка подписи»): подпись собрана неверно; подписывайте черезInvoiceSignatureHelperиз SDK по каноническому телу счёта-фактуры (не вручную), затем переотправьте. В конце удалите запись FAILED черезdeleteFailedSigned(delete-failed --id), чтобы не осталось дубля по номеру.
Учётные данные и обвязка. Храните файл ключа и секреты стандартным образом — см. Скрипты рабочего пространства и боковая панель о том, где лежат учётные данные интеграций и про заголовок @plank-integration, и Интеграции о том, как появляются подключённые инструменты. Прочитайте это руководство, прежде чем импровизировать с обвязкой.
Готовая референс-реализация
Скопируйте эти файлы дословно, не выводя их заново — переписывание Java-клиента это главная потеря времени, и в нём уже учтены все подводные камни выше (только Java-8 API, закрытие «зависшей» сессии и правильная подпись через InvoiceSignatureHelper). Это тот самый код, проверенный вживую на проде для ИП — и чтение (подписанная сессия → queryInvoice), и реальная выписка счёта-фактуры (syncInvoice). Раскладка:
scripts/esf/esf.py— CLI-обёртка (ниже)scripts/esf/client/EsfClient.java— клиент CXF + WSS4J + Kalkan: подписанная сессия,queryInvoice,get/errors/delete-failed,validateи подписанныйsyncInvoice(ниже)scripts/esf/credentials/config.json— заполняет пользователь (выше)scripts/esf/sdk/…— распакованный SDK
esf.py сам находит JDK 8, собирает classpath из всех jar под sdk/, компилирует клиент один раз (пропускает перекомпиляцию, если классы уже есть), держит окно ≤1 квартала и отказывается делать submit без --confirm-submit. Клиент закрывает зависшую сессию ЭСФ перед открытием новой (ЭСФ разрешает только одну открытую сессию на пользователя) и закрывает её на выходе.
Чтение счетов-фактур — после распаковки SDK и заполнения config.json:
python3 scripts/esf/esf.py check # конфиг + jar SDK + Java 8 в порядке?
python3 scripts/esf/esf.py list --direction OUTBOUND --from 2026-07-01 --to 2026-07-31 # OUTBOUND=выписанные, INBOUND=полученные; окно ≤ 1 квартала
python3 scripts/esf/esf.py get --id <INVOICE_ID> --out scripts/esf/outbox/<INVOICE_ID>.xml # тело одной счёт-фактуры + статус
Выписка счёта-фактуры — последовательность, работающая с первого раза (accepted это ещё не registered — всегда проверяйте):
python3 scripts/esf/esf.py prepare-invoice invoice.json --out scripts/esf/outbox/invoice.xml # только локальный предпросмотр, без отправки
python3 scripts/esf/esf.py validate scripts/esf/outbox/invoiceContainer.xml # парсится как настоящий invoiceContainer ЭСФ
python3 scripts/esf/esf.py submit scripts/esf/outbox/invoiceContainer.xml --confirm-submit # подписывает + загружает → {accepted:[{id,...}]}
# ПРОВЕРКА (ЭСФ обрабатывает асинхронно — "accepted" с errors:[] всё равно может уйти в FAILED):
python3 scripts/esf/esf.py get --id <ID> --out scripts/esf/outbox/<ID>.xml # статус CREATED = зарегистрирован, FAILED = отклонён
python3 scripts/esf/esf.py errors --id <ID> # причина при FAILED, напр. SIGNATURE_VERIFICATION_FAILED
python3 scripts/esf/esf.py delete-failed --id <ID> # удалить запись FAILED перед повтором (иначе дубль по num)
scripts/esf/esf.py
#!/usr/bin/env python3
# @plank-integration
# provider: esf
# service: invoices
# name: ESF invoices
# description: Prepare, list, and submit Kazakhstan ESF invoices for the ИП profile.
# requires:
# - file: scripts/esf/credentials/config.json
# - file: scripts/esf/sdk
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from datetime import date, datetime
from pathlib import Path
from xml.sax.saxutils import escape
SCRIPT_DIR = Path(__file__).resolve().parent
WORKSPACE_ROOT = SCRIPT_DIR.parents[1]
CONFIG_PATH = SCRIPT_DIR / "credentials" / "config.json"
SDK_DIR = SCRIPT_DIR / "sdk"
JAVA_MAIN = "kz.plank.esf.EsfClient"
CLIENT_SRC = SCRIPT_DIR / "client" / "EsfClient.java"
CLIENT_CLASSES = SCRIPT_DIR / "client" / "classes"
JAVA_HOME_CANDIDATES = sorted(Path.home().glob(".local/jdks/jdk8*")) + sorted(Path.home().glob(".local/jdks/*jdk8*"))
REQUIRED_CONFIG = ["iin", "tin", "businessProfileType", "esfPassword", "keyPath", "keyPin"]
def die(message: str) -> None:
raise SystemExit(message)
def load_config() -> dict:
if not CONFIG_PATH.exists():
die(f"Missing credentials config: {CONFIG_PATH}. Copy config.example.json to config.json first.")
config = json.loads(CONFIG_PATH.read_text())
missing = [key for key in REQUIRED_CONFIG if not config.get(key)]
if missing:
die(f"Missing required config fields: {', '.join(missing)}")
if config.get("businessProfileType") != "ENTREPRENEUR":
die("For ИП, businessProfileType must be ENTREPRENEUR.")
key_path = resolve_workspace_path(config["keyPath"])
if not key_path.exists():
die(f"ЭЦП key file not found: {key_path}")
return config
def resolve_workspace_path(value: str) -> Path:
path = Path(value)
if path.is_absolute():
return path
return WORKSPACE_ROOT / path
def sdk_jars() -> list[Path]:
if not SDK_DIR.exists():
return []
return sorted(SDK_DIR.rglob("*.jar"))
def classpath() -> str:
jars = sdk_jars()
paths = [str(CLIENT_CLASSES), *(str(path) for path in jars)]
return os.pathsep.join(paths)
def run_java(args: list[str]) -> None:
config = load_config()
ensure_java_ready()
key_path = resolve_workspace_path(config["keyPath"])
cmd = [
str(java_bin("java")),
"-cp",
classpath(),
JAVA_MAIN,
"--iin",
config["iin"],
"--tin",
config["tin"],
"--business-profile-type",
config["businessProfileType"],
"--password",
config["esfPassword"],
"--key-path",
str(key_path),
"--key-pin",
config["keyPin"],
*args,
]
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as exc:
die(f"ESF Java command failed with exit code {exc.returncode}. See the Java error above for details.")
def ensure_java_ready() -> None:
if not sdk_jars():
die("Missing ESF SDK jars under scripts/esf/sdk. Download the official SDK from esf.gov.kz first.")
if not CLIENT_CLASSES.exists() or not any(CLIENT_CLASSES.rglob("*.class")):
compile_client()
def compile_client() -> None:
CLIENT_CLASSES.mkdir(parents=True, exist_ok=True)
cmd = [str(java_bin("javac")), "-encoding", "UTF-8", "-cp", classpath(), "-d", str(CLIENT_CLASSES), str(CLIENT_SRC)]
try:
subprocess.run(cmd, check=True)
except FileNotFoundError:
die("javac not found. ESF SDK requires Java 8 with javac available.")
except subprocess.CalledProcessError:
die(f"Failed to compile ESF Java client. Confirm the official SDK jars are in {SDK_DIR}.")
def java_bin(name: str) -> Path:
for home in JAVA_HOME_CANDIDATES:
binary = home / "bin" / name
if binary.exists():
return binary
return Path(name)
def check() -> None:
print("Checking ESF integration setup...")
if CONFIG_PATH.exists():
config = load_config()
print(f"config: ok ({CONFIG_PATH})")
print(f"profile: {config['businessProfileType']} tin={config['tin']}")
else:
print(f"config: missing ({CONFIG_PATH})")
jars = sdk_jars()
print(f"sdk jars: {len(jars)} found under {SDK_DIR}")
if jars:
for jar in jars[:10]:
print(f"- {jar.relative_to(WORKSPACE_ROOT)}")
if len(jars) > 10:
print(f"- ... {len(jars) - 10} more")
print(f"java: {java_bin('java')}")
print("java client source: ok")
def parse_iso_date(value: str) -> date:
try:
return date.fromisoformat(value)
except ValueError as exc:
die(f"Invalid date {value!r}; expected YYYY-MM-DD")
def validate_quarter_window(date_from: str, date_to: str) -> None:
start = parse_iso_date(date_from)
end = parse_iso_date(date_to)
if end < start:
die("--to must be on or after --from")
if (end - start).days > 92:
die("ESF query window must be no more than one quarter. Split the request into smaller ranges.")
def prepare_invoice(input_path: Path, output_path: Path) -> None:
data = json.loads(input_path.read_text())
required = ["number", "date", "sellerTin", "buyerTin", "buyerName", "currencyCode", "items"]
missing = [key for key in required if not data.get(key)]
if missing:
die(f"Invoice JSON missing required fields: {', '.join(missing)}")
if not data["items"]:
die("Invoice JSON must contain at least one item")
for index, item in enumerate(data["items"], start=1):
item_missing = [key for key in ["name", "quantity", "unitPrice", "amount"] if item.get(key) in (None, "")]
if item_missing:
die(f"Item {index} missing fields: {', '.join(item_missing)}")
xml = invoice_review_xml(data)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(xml, encoding="utf-8")
print(f"Prepared review XML: {output_path}")
print("Review this file before running submit. ESF has no unsigned draft API.")
def invoice_review_xml(data: dict) -> str:
# This is a review-side XML envelope. The Java client maps/signs it into the SDK invoice type before syncInvoice.
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
"<plankEsfInvoice>",
f" <number>{escape(str(data['number']))}</number>",
f" <date>{escape(str(data['date']))}</date>",
f" <sellerTin>{escape(str(data['sellerTin']))}</sellerTin>",
f" <buyerTin>{escape(str(data['buyerTin']))}</buyerTin>",
f" <buyerName>{escape(str(data['buyerName']))}</buyerName>",
f" <currencyCode>{escape(str(data['currencyCode']))}</currencyCode>",
" <items>",
]
for item in data["items"]:
lines.extend(
[
" <item>",
f" <name>{escape(str(item['name']))}</name>",
f" <quantity>{escape(str(item['quantity']))}</quantity>",
f" <unitPrice>{escape(str(item['unitPrice']))}</unitPrice>",
f" <amount>{escape(str(item['amount']))}</amount>",
" </item>",
]
)
lines.extend([" </items>", "</plankEsfInvoice>", ""])
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Kazakhstan ESF helper for ИП invoices.")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("check", help="Check local credential and SDK setup.")
p = sub.add_parser("list", help="List invoices through ESF queryInvoice.")
p.add_argument("--direction", choices=["OUTBOUND", "INBOUND"], required=True)
p.add_argument("--from", dest="date_from", required=True)
p.add_argument("--to", dest="date_to", required=True)
p.add_argument("--date-field", choices=["issue", "last-update"], default="issue")
p.add_argument("--page-start", choices=["0", "1"], default="1")
p = sub.add_parser("get", help="Fetch invoice body by ESF invoice id.")
p.add_argument("--id", required=True)
p.add_argument("--out", type=Path, required=True)
p = sub.add_parser("errors", help="Fetch ESF processing errors for an invoice id.")
p.add_argument("--id", required=True)
p = sub.add_parser("delete-failed", help="Delete a failed ESF invoice by id.")
p.add_argument("--id", required=True)
p = sub.add_parser("validate", help="Parse an official ESF invoiceContainer XML without submitting.")
p.add_argument("xml_file", type=Path)
p = sub.add_parser("prepare-invoice", help="Create a local review XML from invoice JSON. Does not submit.")
p.add_argument("json_file", type=Path)
p.add_argument("--out", type=Path, required=True)
p = sub.add_parser("submit", help="Sign and submit a prepared invoice XML. Registers the invoice immediately.")
p.add_argument("xml_file", type=Path)
p.add_argument("--confirm-submit", action="store_true", help="Required safety confirmation.")
args = parser.parse_args()
if args.command == "check":
check()
elif args.command == "list":
validate_quarter_window(args.date_from, args.date_to)
run_java(["list", "--direction", args.direction, "--from", args.date_from, "--to", args.date_to, "--date-field", args.date_field, "--page-start", args.page_start])
elif args.command == "prepare-invoice":
prepare_invoice(args.json_file, args.out)
elif args.command == "get":
run_java(["get", "--id", args.id, "--out", str(args.out)])
elif args.command == "errors":
run_java(["errors", "--id", args.id])
elif args.command == "delete-failed":
run_java(["delete-failed", "--id", args.id])
elif args.command == "validate":
if not args.xml_file.exists():
die(f"Invoice XML not found: {args.xml_file}")
run_java(["validate", "--xml", str(args.xml_file)])
elif args.command == "submit":
if not args.confirm_submit:
die("Refusing to submit without --confirm-submit. This signs and registers the invoice in ESF immediately.")
if not args.xml_file.exists():
die(f"Invoice XML not found: {args.xml_file}")
run_java(["submit", "--xml", str(args.xml_file)])
return 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/esf/client/EsfClient.java
package kz.plank.esf;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.x500.X500PrivateCredential;
import kz.gov.pki.kalkan.jce.provider.KalkanProvider;
import kz.gov.pki.kalkan.xmldsig.KncaXS;
import kz.gov.pki.provider.utils.XMLUtil;
import kz.gov.pki.provider.utils.model.SigningEntity;
import org.apache.cxf.endpoint.Client;
import org.apache.cxf.frontend.ClientProxy;
import org.apache.cxf.jaxws.JaxWsProxyFactoryBean;
import org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor;
import org.apache.wss4j.common.ext.WSPasswordCallback;
import org.apache.wss4j.dom.WSConstants;
import org.apache.wss4j.dom.handler.WSHandlerConstants;
import ru.uss.core.api.session.CreateAuthTicketRequest;
import ru.uss.core.api.session.CreateAuthTicketResponse;
import ru.uss.core.api.session.CreateSessionResponse;
import ru.uss.core.api.session.CreateSessionSignedRequest;
import ru.uss.core.api.session.CloseSessionBySignedCredentialsRequest;
import ru.uss.core.model.SignatureType;
import ru.uss.core.model.SourceType;
import ru.uss.core.utils.JAXBUtils;
import ru.uss.core.model.Error;
import ru.uss.esf.api1.auth.AuthServiceAPI1;
import ru.uss.esf.api1.exception.AccessDeniedException;
import ru.uss.esf.api1.invoice.InvoiceServiceAPI1;
import ru.uss.esf.api1.invoice.InvoiceByIdRequest;
import ru.uss.esf.api1.invoice.InvoiceError;
import ru.uss.esf.api1.invoice.InvoiceErrorByIdRequest;
import ru.uss.esf.api1.invoice.InvoiceErrorByIdResponse;
import ru.uss.esf.api1.invoice.DeleteInvoiceByIdRequest;
import ru.uss.esf.api1.invoice.DeleteInvoiceByIdResponse;
import ru.uss.esf.api1.invoice.QueryInvoiceRequest;
import ru.uss.esf.api1.invoice.QueryInvoiceResponse;
import ru.uss.esf.api1.session.SessionServiceAPI1;
import ru.uss.esf.api1.upload.UploadInvoiceServiceAPI1;
import ru.uss.esf.api1.upload.SyncInvoiceRequest;
import ru.uss.esf.api1.upload.SyncInvoiceResponse;
import ru.uss.esf.api1.upload.StandardResponse;
import ru.uss.esf.core.utils.InvoiceSignatureHelper;
import ru.uss.esf.model.invoice.InvoiceDirection;
import ru.uss.esf.model.invoice.InvoiceInfo;
import ru.uss.esf.model.invoice.InvoiceUploadInfo;
import ru.uss.esf.model.DeleteResult;
import ru.uss.esf.model.invoice.abstractinvoice.AbstractInvoice;
import ru.uss.esf.model.invoice.container.InvoiceContainer;
import ru.uss.esf.model.usermng.BusinessProfileType;
import ru.ussgroup.security.trusty.TrustyUtils;
/**
* Thin adapter for the official ESF SDK.
*/
public final class EsfClient {
private static final String BASE_WS = "https://esf.gov.kz:8443/esf-web/ws/api1/";
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
private static final Pattern OPEN_SESSION_PATTERN = Pattern.compile("session with id ([^\\s]+)");
private EsfClient() {}
public static void main(String[] rawArgs) throws Exception {
Map<String, String> args = parseArgs(rawArgs);
String command = args.get("command");
Config config = Config.fromArgs(args);
if ("list".equals(command)) {
require(args, "direction", "from", "to");
listInvoices(config, args.get("direction"), LocalDate.parse(args.get("from")), LocalDate.parse(args.get("to")), args.containsKey("date-field") ? args.get("date-field") : "issue", args.containsKey("page-start") ? Integer.parseInt(args.get("page-start")) : 1);
} else if ("submit".equals(command)) {
require(args, "xml");
submitInvoice(config, new java.io.File(args.get("xml")).toPath());
} else if ("get".equals(command)) {
require(args, "id", "out");
getInvoice(config, Long.parseLong(args.get("id")), new java.io.File(args.get("out")).toPath());
} else if ("errors".equals(command)) {
require(args, "id");
getInvoiceErrors(config, Long.parseLong(args.get("id")));
} else if ("delete-failed".equals(command)) {
require(args, "id");
deleteFailedInvoice(config, Long.parseLong(args.get("id")));
} else if ("validate".equals(command)) {
require(args, "xml");
validateInvoiceXml(new java.io.File(args.get("xml")).toPath());
} else {
throw new IllegalArgumentException("Unknown command: " + command);
}
}
private static void listInvoices(Config config, String direction, LocalDate from, LocalDate to, String dateField, int pageStart) throws Exception {
String sessionId = createSignedSession(config);
InvoiceServiceAPI1 invoiceService = proxy(InvoiceServiceAPI1.class, "InvoiceService", false, config);
int page = pageStart;
int total = 0;
System.out.println("{");
System.out.println(" \"sessionId\": \"" + json(sessionId) + "\",");
System.out.println(" \"invoices\": [");
boolean first = true;
while (true) {
QueryInvoiceRequest request = new QueryInvoiceRequest();
request.setSessionId(sessionId);
QueryInvoiceRequest.Criteria criteria = new QueryInvoiceRequest.Criteria();
criteria.setDirection(InvoiceDirection.valueOf(direction));
if ("last-update".equals(dateField)) {
criteria.setDateFrom(asDate(from));
criteria.setDateTo(asDate(to.plusDays(1)));
criteria.setLastUpdateDateFrom(asDate(from));
criteria.setLastUpdateDateTo(asDate(to.plusDays(1)));
} else {
criteria.setDateFrom(asDate(from));
criteria.setDateTo(asDate(to.plusDays(1)));
}
criteria.setPageNum(page);
request.setCriteria(criteria);
QueryInvoiceResponse response = invoiceService.queryInvoice(request);
for (InvoiceInfo info : response.getInvoiceInfoList()) {
if (!first) {
System.out.println(",");
}
System.out.print(" " + invoiceJson(info));
first = false;
total++;
}
if (response.isLastBlock()) {
break;
}
page++;
}
System.out.println();
System.out.println(" ],");
System.out.println(" \"count\": " + total);
System.out.println("}");
}
private static void submitInvoice(Config config, Path reviewXml) throws Exception {
if (!Files.exists(reviewXml)) {
throw new IllegalArgumentException("Invoice XML not found: " + reviewXml);
}
String xml = new String(Files.readAllBytes(reviewXml), StandardCharsets.UTF_8);
if (xml.contains("<plankEsfInvoice")) {
throw new IllegalArgumentException("This is a Plank review XML, not an official ESF invoiceContainer XML. Prepare or provide a valid ESF XML before submitting.");
}
X500PrivateCredential credential = credential(config);
Security.addProvider(new KalkanProvider());
InvoiceContainer container = JAXBUtils.toObject(xml, InvoiceContainer.class);
if (container.getInvoiceSet() == null || container.getInvoiceSet().isEmpty()) {
throw new IllegalArgumentException("ESF XML contains no invoices in invoiceSet");
}
List<InvoiceUploadInfo> uploadInfos = new ArrayList<InvoiceUploadInfo>();
String certificate = TrustyUtils.toBase64(credential.getCertificate());
for (AbstractInvoice invoice : container.getInvoiceSet()) {
String body = InvoiceSignatureHelper.extractSignatureData(invoice);
InvoiceUploadInfo uploadInfo = InvoiceUploadInfo.fromInvoice(invoice);
uploadInfo.setInvoiceBody(body);
uploadInfo.setVersion(invoice.getVersion());
uploadInfo.setCertificate(certificate);
uploadInfo.setSignature(InvoiceSignatureHelper.sign(invoice, credential));
uploadInfo.setSignatureType(SignatureType.COMPANY);
uploadInfos.add(uploadInfo);
}
String sessionId = createSignedSession(config);
SyncInvoiceRequest request = new SyncInvoiceRequest(uploadInfos, certificate);
request.setSessionId(sessionId);
UploadInvoiceServiceAPI1 uploadService = proxy(UploadInvoiceServiceAPI1.class, "UploadInvoiceService", false, config);
SyncInvoiceResponse response = uploadService.syncInvoice(request);
printSubmitResponse(response);
}
private static void validateInvoiceXml(Path xmlPath) throws Exception {
if (!Files.exists(xmlPath)) {
throw new IllegalArgumentException("Invoice XML not found: " + xmlPath);
}
String xml = new String(Files.readAllBytes(xmlPath), StandardCharsets.UTF_8);
InvoiceContainer container = JAXBUtils.toObject(xml, InvoiceContainer.class);
int count = container.getInvoiceSet() == null ? 0 : container.getInvoiceSet().size();
System.out.println("{\"valid\":true,\"invoiceCount\":" + count + "}");
}
private static void getInvoice(Config config, Long id, Path out) throws Exception {
String sessionId = createSignedSession(config);
InvoiceServiceAPI1 invoiceService = proxy(InvoiceServiceAPI1.class, "InvoiceService", false, config);
InvoiceByIdRequest request = new InvoiceByIdRequest();
request.setSessionId(sessionId);
request.setIdList(Collections.singletonList(id));
QueryInvoiceResponse response = invoiceService.queryInvoiceById(request);
if (response.getInvoiceInfoList() == null || response.getInvoiceInfoList().isEmpty()) {
throw new IllegalArgumentException("No invoice returned for id " + id);
}
InvoiceInfo info = response.getInvoiceInfoList().get(0);
String body = info.getInvoiceBody();
if (body == null && info.getInvoice() != null) {
body = InvoiceSignatureHelper.toXML(info.getInvoice());
}
if (body == null) {
throw new IllegalStateException("Invoice " + id + " has no invoice body in API response");
}
Files.createDirectories(out.getParent());
Files.write(out, body.getBytes(StandardCharsets.UTF_8));
System.out.println("Saved invoice body: " + out);
System.out.println(invoiceJson(info));
}
private static void getInvoiceErrors(Config config, Long id) throws Exception {
String sessionId = createSignedSession(config);
InvoiceServiceAPI1 invoiceService = proxy(InvoiceServiceAPI1.class, "InvoiceService", false, config);
InvoiceErrorByIdRequest request = new InvoiceErrorByIdRequest();
request.setSessionId(sessionId);
request.setIdList(Collections.singletonList(id));
InvoiceErrorByIdResponse response = invoiceService.queryInvoiceErrorById(request);
System.out.println("{\"invoiceErrors\":[");
List<InvoiceError> errors = response.getInvoiceErrorList();
for (int i = 0; errors != null && i < errors.size(); i++) {
InvoiceError invoiceError = errors.get(i);
if (i > 0) {
System.out.println(",");
}
System.out.print(" {\"invoiceId\":" + invoiceError.getInvoiceId() + ",\"errors\":" + errorListJson(invoiceError.getErrors()) + "}");
}
System.out.println();
System.out.println("]}");
}
private static void deleteFailedInvoice(Config config, Long id) throws Exception {
X500PrivateCredential credential = credential(config);
String sessionId = createSignedSession(config);
InvoiceServiceAPI1 invoiceService = proxy(InvoiceServiceAPI1.class, "InvoiceService", false, config);
DeleteInvoiceByIdRequest request = new DeleteInvoiceByIdRequest();
request.setSessionId(sessionId);
request.setIdList(Collections.singletonList(id));
request.setX509Certificate(TrustyUtils.toBase64(credential.getCertificate()));
String signableData = InvoiceSignatureHelper.toXML(request.getSignableData());
request.setSignature(TrustyUtils.sign(signableData, credential));
DeleteInvoiceByIdResponse response = invoiceService.deleteInvoiceById(request);
System.out.println("{\"deleteResults\":[");
List<DeleteResult> results = response.getResultList();
for (int i = 0; results != null && i < results.size(); i++) {
DeleteResult result = results.get(i);
if (i > 0) {
System.out.println(",");
}
System.out.print(" {\"invoiceId\":" + nullableNumber(result.getInvoiceId()) + ",\"deleted\":" + result.isDeleted() + "}");
}
System.out.println();
System.out.println("]}");
}
private static String createSignedSession(Config config) throws Exception {
X500PrivateCredential credential = credential(config);
AuthServiceAPI1 authService = proxy(AuthServiceAPI1.class, "AuthService", false, config);
CreateAuthTicketRequest authRequest = new CreateAuthTicketRequest();
authRequest.setIin(config.iin);
authRequest.setTtlInMinutes(5);
CreateAuthTicketResponse ticketResponse = authService.createAuthTicket(authRequest);
Security.addProvider(new KalkanProvider());
KncaXS.loadXMLSecurity();
List<X509Certificate> chain = new ArrayList<X509Certificate>();
chain.add(credential.getCertificate());
String signedTicket = XMLUtil.createXmlSignature(
new SigningEntity(credential.getPrivateKey(), chain),
ticketResponse.getAuthTicketXml(),
Security.getProvider(KalkanProvider.PROVIDER_NAME)
);
CreateSessionSignedRequest sessionRequest = new CreateSessionSignedRequest();
sessionRequest.setTin(config.tin);
sessionRequest.setBusinessProfileType(BusinessProfileType.valueOf(config.businessProfileType));
sessionRequest.setSourceType(SourceType.OTHER);
sessionRequest.setSignedAuthTicket(signedTicket);
sessionRequest.setAuthWithCert(true);
SessionServiceAPI1 sessionService = proxy(SessionServiceAPI1.class, "SessionService", true, config);
try {
CreateSessionResponse sessionResponse = sessionService.createSessionSigned(sessionRequest);
return sessionResponse.getSessionId();
} catch (AccessDeniedException ex) {
String message = ex.getMessage();
Matcher matcher = OPEN_SESSION_PATTERN.matcher(message == null ? "" : message);
if (!matcher.find()) {
throw ex;
}
CloseSessionBySignedCredentialsRequest closeRequest = new CloseSessionBySignedCredentialsRequest();
closeRequest.setTin(config.tin);
closeRequest.setBusinessProfileType(BusinessProfileType.valueOf(config.businessProfileType));
closeRequest.setSignedAuthTicket(signedTicket);
sessionService.closeSessionBySignedCredentials(closeRequest);
CreateSessionResponse sessionResponse = sessionService.createSessionSigned(sessionRequest);
return sessionResponse.getSessionId();
}
}
private static X500PrivateCredential credential(Config config) {
if (!Files.exists(new java.io.File(config.keyPath).toPath())) {
throw new IllegalArgumentException("ЭЦП key file not found: " + config.keyPath);
}
if (!"ENTREPRENEUR".equals(config.businessProfileType)) {
throw new IllegalArgumentException("For ИП, businessProfileType must be ENTREPRENEUR");
}
return TrustyUtils.loadCredentialFromFile(config.keyPath, config.keyPin);
}
@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> serviceClass, String serviceName, boolean usernameToken, Config config) {
JaxWsProxyFactoryBean factory = new JaxWsProxyFactoryBean();
factory.setServiceClass(serviceClass);
factory.setAddress(BASE_WS + serviceName);
T service = (T) factory.create();
Client client = ClientProxy.getClient(service);
client.getRequestContext().put("javax.xml.ws.client.connectionTimeout", "30000");
client.getRequestContext().put("javax.xml.ws.client.receiveTimeout", "60000");
if (usernameToken) {
Map<String, Object> props = new HashMap<String, Object>();
props.put(WSHandlerConstants.ACTION, WSHandlerConstants.USERNAME_TOKEN);
props.put(WSHandlerConstants.USER, config.iin);
props.put(WSHandlerConstants.PASSWORD_TYPE, WSConstants.PW_TEXT);
props.put(WSHandlerConstants.PW_CALLBACK_REF, new StaticPasswordCallback(config.password));
client.getOutInterceptors().add(new WSS4JOutInterceptor(props));
}
return service;
}
private static Date asDate(LocalDate value) {
return Date.from(value.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
private static String invoiceJson(InvoiceInfo info) {
AbstractInvoice invoice = info.getInvoice();
String num = invoice == null ? null : invoice.getNum();
String date = invoice == null || invoice.getDate() == null ? null : DATE_FORMAT.format(invoice.getDate());
String amount = invoice == null || invoice.getTotalPriceWithTax() == null ? null : invoice.getTotalPriceWithTax().toPlainString();
return "{"
+ "\"invoiceId\":" + nullableNumber(info.getInvoiceId())
+ ",\"registrationNumber\":" + nullableString(info.getRegistrationNumber())
+ ",\"num\":" + nullableString(num)
+ ",\"date\":" + nullableString(date)
+ ",\"status\":" + nullableString(info.getInvoiceStatus() == null ? null : info.getInvoiceStatus().name())
+ ",\"amount\":" + nullableString(amount)
+ "}";
}
private static void printSubmitResponse(SyncInvoiceResponse response) {
System.out.println("{");
System.out.println(" \"accepted\": " + standardListJson(response.getAcceptedSet()) + ",");
System.out.println(" \"declined\": " + standardListJson(response.getDeclinedSet()));
System.out.println("}");
}
private static String standardListJson(List<StandardResponse> responses) {
if (responses == null || responses.isEmpty()) {
return "[]";
}
StringBuilder out = new StringBuilder("[");
for (int i = 0; i < responses.size(); i++) {
StandardResponse response = responses.get(i);
if (i > 0) {
out.append(",");
}
out.append("{\"id\":").append(nullableNumber(response.getId()))
.append(",\"idString\":").append(nullableString(response.getIdString()))
.append(",\"num\":").append(nullableString(response.getNum()))
.append(",\"date\":").append(nullableString(response.getDate() == null ? null : DATE_FORMAT.format(response.getDate())))
.append(",\"errors\":").append(errorListJson(response.getErrors()))
.append("}");
}
out.append("]");
return out.toString();
}
private static String errorListJson(List<Error> errors) {
if (errors == null || errors.isEmpty()) {
return "[]";
}
StringBuilder out = new StringBuilder("[");
for (int i = 0; i < errors.size(); i++) {
Error error = errors.get(i);
if (i > 0) {
out.append(",");
}
out.append("{\"property\":").append(nullableString(error.getProperty()))
.append(",\"code\":").append(nullableString(error.getErrorCode() == null ? null : String.valueOf(error.getErrorCode())))
.append(",\"text\":").append(nullableString(error.getText()))
.append("}");
}
out.append("]");
return out.toString();
}
private static String nullableNumber(Long value) {
return value == null ? "null" : String.valueOf(value);
}
private static String nullableString(String value) {
return value == null ? "null" : "\"" + json(value) + "\"";
}
private static String json(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
}
private static Map<String, String> parseArgs(String[] rawArgs) {
Map<String, String> args = new HashMap<>();
for (int i = 0; i < rawArgs.length; i++) {
String arg = rawArgs[i];
if ("list".equals(arg) || "submit".equals(arg) || "get".equals(arg) || "errors".equals(arg) || "delete-failed".equals(arg) || "validate".equals(arg)) {
args.put("command", arg);
continue;
}
if (!arg.startsWith("--")) {
throw new IllegalArgumentException("Unexpected argument: " + arg);
}
String key = arg.substring(2);
if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("--")) {
throw new IllegalArgumentException("Missing value for --" + key);
}
args.put(key, rawArgs[++i]);
}
return args;
}
private static void require(Map<String, String> args, String... names) {
for (String name : names) {
if (!args.containsKey(name) || args.get(name).isEmpty()) {
throw new IllegalArgumentException("Missing --" + name);
}
}
}
private static final class Config {
final String iin;
final String tin;
final String businessProfileType;
final String password;
final String keyPath;
final String keyPin;
Config(String iin, String tin, String businessProfileType, String password, String keyPath, String keyPin) {
this.iin = iin;
this.tin = tin;
this.businessProfileType = businessProfileType;
this.password = password;
this.keyPath = keyPath;
this.keyPin = keyPin;
}
static Config fromArgs(Map<String, String> args) {
require(args, "iin", "tin", "business-profile-type", "password", "key-path", "key-pin");
return new Config(
args.get("iin"),
args.get("tin"),
args.get("business-profile-type"),
args.get("password"),
args.get("key-path"),
args.get("key-pin")
);
}
}
private static final class StaticPasswordCallback implements CallbackHandler {
private final String password;
StaticPasswordCallback(String password) {
this.password = password;
}
public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
for (Callback callback : callbacks) {
if (callback instanceof WSPasswordCallback) {
((WSPasswordCallback) callback).setPassword(password);
} else {
throw new UnsupportedCallbackException(callback);
}
}
}
}
}
scripts/esf/invoice.example.json
{
"number": "POWR-2026-07",
"date": "2026-07-01",
"sellerTin": "YOUR_IP_TIN_OR_IIN",
"buyerTin": "BUYER_TIN",
"buyerName": "Buyer legal name",
"currencyCode": "USD",
"items": [
{
"name": "Software development services",
"quantity": "1",
"unitPrice": "5700.00",
"amount": "5700.00"
}
]
}
.gitignore (корень рабочего пространства)
scripts/esf/credentials/config.json
scripts/esf/credentials/*.p12
scripts/esf/sdk/
scripts/esf/client/classes/
scripts/esf/outbox/*.signed.xml
reports/esf/
См. также: Автоматизации для выгрузки счетов-фактур или сверки по расписанию и Результаты работы о том, как создаются и передаются отчёты.