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. Рабочая последовательность:

  1. AuthService.createAuthTicket(iin, ttlInMinutes)без WS-Security (это предварительный вызов; добавление security-заголовка вернёт пустой ответ). Возвращает тикет <authSign>.
  2. Подпишите тикет как enveloped XMLDsig ключом — XMLUtil.createXmlSignature(new SigningEntity(privateKey, [cert]), ticketXml, kalkanProvider) после KncaXS.loadXMLSecurity(). Этот же вызов на самом деле нужен любому госпорталу РК, который открывает окно NCALayer; в общем виде он описан в Подписи ключом ЭЦП РК.
  3. SessionService.createSessionSigned({ tin, businessProfileType, signedAuthTicket, authWithCert: true })с WS-Security UsernameToken = ваш ИИН + пароль портала ЭСФ. Возвращает sessionId. Берите tin и businessProfileType из config.json (см. таблицу типов профиля выше — ENTREPRENEUR для ИП, ADMIN_ENTERPRISE для ТОО).
  4. InvoiceService.queryInvoice({ sessionId, criteria: { direction, dateFrom, dateTo, pageNum } })без WS-Security (вызов авторизуется через sessionId в теле). Постраничный обход по isLastBlock. directionOUTBOUND (выписанные) или 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/

См. также: Автоматизации для выгрузки счетов-фактур или сверки по расписанию и Результаты работы о том, как создаются и передаются отчёты.