Plank help · updated 2026-09-09
Connecting ESF (electronic invoices)
Connect Kazakhstan's electronic invoice system (ИС ЭСФ, esf.gov.kz) so your assistant can list and report on your invoices and — on your say-so — issue them, signing with your ЭЦП key over the official API. No NCALayer pop-up, no per-operation QR.
Agents: fetch the raw markdown of this page at /en/help/connecting-esf.md
Connecting ESF (electronic invoices)
Your assistant can work directly with ИС ЭСФ (esf.gov.kz) — Kazakhstan's electronic-invoice system — using the same ЭЦП key you'd normally sign in with. Once it's connected, you ask in plain language ("show my issued invoices for this quarter", "list invoices from this supplier") and get finished results, without clicking through the ЭСФ portal.
This uses ЭСФ's official integration API — the same one accounting systems like 1C use — so signing happens programmatically with your key. There's no NCALayer pop-up and no QR code to scan for every operation: that per-operation signing is a property of the web portal only, not of the system.
What your assistant can do once ЭСФ is connected
- List and report on your invoices — issued (outgoing) and received (incoming) for a period, filtered by status, counterparty, or date. Delivered as Excel and PDF like any other file.
- Reconcile — cross-check ЭСФ invoices against your own records or your 1C data.
This connection does not put invoices into 1C. It reads the portal and hands the data back as files. Turning a received ЭСФ into 1C accounting documents — a «Счет-фактура полученный» and a «Поступление товаров и услуг» — is Importing incoming ЭСФ into 1C, and that reads
Document_ЭСФinside 1C. Rows get there through 1C's own «Обмен с ИС ЭСФ», which the accountant configures inside 1C. Connecting here does not switch it on.
- Issue invoices — you describe the sale in words; the assistant assembles the invoice, signs it with your key, and submits it to ЭСФ. This happens only when you ask and confirm (see safety below).
Finished reports and confirmations are saved as files in your workspace.
What you need before connecting
- Your ЭЦП key file — the GOST key (
GOST….p12) you use for ЭСФ — and its PIN. - Your ЭСФ portal password — the one paired with your IIN when you registered on esf.gov.kz. The API needs it alongside the key.
- Your IIN, and your ИП / company TIN if it differs from your IIN.
You don't install anything — the assistant runs the official ЭСФ signing toolkit itself.
Connecting
You connect by telling the assistant in chat and giving it:
- your ЭЦП key file (upload it to the workspace),
- its PIN,
- your ЭСФ portal password, and
- your IIN (and TIN if different).
The assistant stores the key and secrets in your workspace's secure credentials and uses them only to reach ЭСФ. It never writes them into notes, logs, or the chat history. It then runs an access check — opens a session in your ИП context and pulls a recent invoice list — and reports back what it sees, in plain language, so you know the connection is healthy.
How it keeps you safe
- Reading changes nothing. Listing and reporting on invoices never alters anything in ЭСФ.
- Issuing only on your say-so. The assistant prepares an invoice and shows it to you; it signs and submits only after you confirm. It won't issue anything on its own.
- No "draft" sits on the government system. ЭСФ has no API call to save an unsigned draft — the only write is a signed submission. So the assistant keeps the prepared invoice on Plank's side for your review and only signs + submits when you approve; nothing half-finished is ever parked on ЭСФ.
- Your key, your control. To sign on your behalf, your ЭЦП key is stored securely in your Plank workspace. You can remove it at any time to cut off access instantly. If you'd prefer your key never leave your own computer, tell the assistant — it can run the signer locally and keep the key on your machine instead.
How it works — recipe for the assistant
Follow this exactly. ЭСФ's API has per-service quirks that make an improvised first attempt fail; the gotchas below are the ones that bite.
Install the SDK & runtime (workspace-local — never system-wide). Reuse what's already there. Everything lives under scripts/esf/. Before downloading, check the cache — a re-run must not refetch ~180 MB:
- SDK (~180 MB). If
scripts/esf/sdk/already has jars (find scripts/esf/sdk -name '*.jar' | head), skip the download. Otherwise fetch the official ЭСФ SDK ("комплект разработчика") — public, from esf.gov.kz → Для разработчиков → API. Direct link:https://kgd.gov.kz/sites/default/files/ftpdata/ESF/esf-sdk-290824%20(1).rar(the filename carries a date and may bump — if it 404s, listhttps://kgd.gov.kz/sites/default/files/ftpdata/ESF/and take the newestesf-sdk-*.rar). - Unpacking the
.rar— the sandbox has nounrar. The base image ships thelibarchivelibrary but nounrar/unar/7zbinary, so a naïveunrar xfails. Extract with eitherbsdtar -xf esf-sdk.rar -C scripts/esf/sdk/(libarchive reads RAR) orpip install --quiet patool && python3 -c "import patoolib; patoolib.extract_archive('esf-sdk.rar', outdir='scripts/esf/sdk')". You get ascripts/esf/sdk/esf-sdk-*/…tree with ~200 jars — the WSDLs, invoice XML templates, generatedru.uss.esf.*client classes, andesf_local_server.jar(the pure-Java Kalkan GOST signer, no native.so). The referenceesf.pybuilds its classpath from all jars undersdk/. - Java 8 (usually already cached). The SDK uses JAX-WS, removed from the JDK in 11+. If
~/.local/jdks/jdk8*already exists, reuse it. Otherwise install a no-sudo Temurin 8 tarball into~/.local/jdks/(the x64 build runs fine under Rosetta on Apple Silicon). The referenceesf.pyauto-detects any~/.local/jdks/jdk8*; JDK 11/17 fail only on the missing JAX-WS.
Config — one real file the user fills in. Create scripts/esf/credentials/config.json with these exact fields (the reference esf.py reads them by name). It's a per-workspace, git-ignored file — put the real values in it and never echo them into chat, logs, or log.md:
{
"iin": "",
"tin": "",
"businessProfileType": "ENTREPRENEUR",
"esfPassword": "",
"keyPath": "scripts/esf/credentials/GOST.p12",
"keyPin": "",
"environment": "production"
}
iin— IIN of the ЭЦП cert holder; it signs the auth ticket and is the UsernameToken login.tin— the entity the session acts as: the IIN for an ИП, the company BIN for a ТОО (readable from the cert).esfPassword— the ЭСФ portal password (not the key PIN).keyPath— workspace-relative path to the GOST.p12key;keyPin— its PIN.businessProfileType— one of the exact enum strings below (pick by the user's entity):
| Value | ЭСФ role | Use for |
|---|---|---|
ADMIN_ENTERPRISE | Администратор юридического лица | ТОО / company (director/admin) |
USER | Пользователь, приглашённый в предприятие | invited company user |
ENTREPRENEUR | Индивидуальный предприниматель | ИП (default) |
ENTREPRENEUR_USER | Пользователь, работающий в ИП | user working under an ИП |
INDIVIDUAL | Физическое лицо | private individual |
LAWYER | ЛЗЧП: Адвокат | lawyer |
BAILIFF | ЛЗЧП: Частный судебный исполнитель | private bailiff |
MEDIATOR | ЛЗЧП: Медиатор | mediator |
Add a workspace .gitignore (see the reference section) covering scripts/esf/credentials/config.json, *.p12, sdk/, and the compiled classes — so the key, PIN, portal password, and the 180 MB SDK never land in git.
Endpoints (production). Base https://esf.gov.kz:8443/esf-web/ws; services at …/api1/<Service> (AuthService, SessionService, InvoiceService, UploadInvoiceService).
Auth — a GOST‑2015 key REQUIRES a signed session. Plain createSession returns METHOD_NOT_SUPPORT_GOST_2015. The working flow:
AuthService.createAuthTicket(iin, ttlInMinutes)— no WS-Security (this is a pre-auth call; adding a security header returns an empty body). Returns an<authSign>ticket.- Sign the ticket as enveloped XMLDsig with the key —
XMLUtil.createXmlSignature(new SigningEntity(privateKey, [cert]), ticketXml, kalkanProvider)afterKncaXS.loadXMLSecurity(). This same call is what every Kazakhstan state portal's NCALayer prompt is really asking for; Signing with a Kazakhstan ЭЦП key covers it generically. SessionService.createSessionSigned({ tin, businessProfileType, signedAuthTicket, authWithCert: true })— with a WS-Security UsernameToken = your IIN + ЭСФ portal password. Returns thesessionId. TaketinandbusinessProfileTypefromconfig.json(see the profile-type table above —ENTREPRENEURfor ИП,ADMIN_ENTERPRISEfor ТОО).InvoiceService.queryInvoice({ sessionId, criteria: { direction, dateFrom, dateTo, pageNum } })— no WS-Security (the call is authenticated bysessionIdin the body). Page through withisLastBlock.directionisOUTBOUND(issued) orINBOUND(received).
Gotchas that fail a first attempt:
- Per-service auth differs. Only
SessionServicetakes the UsernameToken.AuthService,InvoiceService, andUploadInvoiceServicetake none — add one and you get an empty/EOF response, not an error. - Query window ≤ 1 quarter. A range over ~90 days fails with "Можно получить список СФ за период не более 1 квартала". Iterate quarter-by-quarter for longer reports.
- Issuing uses
UploadInvoiceService.syncInvoice(the client'screateSigned/create): build the invoice XML from the SDK templates, sign each one with the key (InvoiceSignatureHelper.sign→ a detached base64 GOST signature over the canonical body), then submit. Confirm with the user first. - No draft API. There is no "save unsigned draft" call —
syncInvoiceregisters a signed fiscal document immediately. Hold the review copy on Plank's side and submit only on approval. - Java 8 rejects
Path.of/Files.readString/List.of/var. These are Java 9+ APIs; on JDK 8 the client fails to compile withcannot find symbol. UsePaths.get(...),new String(Files.readAllBytes(p), StandardCharsets.UTF_8), andArrays.asList(...). The reference client stays Java-8-clean. - One open session per user. A
createSessionSignedwhile a prior session is still open fails withAccessDeniedException: User already has opened session with id …. Close the stale session first (SessionService.closeSessionBySignedCredentials) — the reference client closes any existing session before opening one, and closes on exit. syncInvoice"accepted" ≠ registered — always verify. The submit returns{accepted:[{id,…,errors:[]}]}even when ЭСФ then processes it toFAILED. After submitting, poll the invoice (get --id/queryInvoice):status: CREATEDmeans registered,status: FAILEDmeans rejected. On FAILED, callfindInvoiceErrors(errors --id) for the reason. The common first-try failure isSIGNATURE_VERIFICATION_FAILED("Ошибка подписи") — the signature was built wrong; sign via the SDK'sInvoiceSignatureHelperover the canonical invoice body (don't hand-roll it), then resubmit. FinallydeleteFailedSigned(delete-failed --id) the FAILED record so it doesn't linger as a duplicate number.
Credentials & wiring. Store the key file and secrets the standard way — see Workspace scripts & the sidebar for where integration credentials live and the @plank-integration header, and Integrations for how the connected tools appear. Read the provider guide before improvising the wiring.
Ready-to-use reference implementation
Copy these files verbatim instead of re-deriving them — re-authoring the Java client is the single biggest time sink, and it already bakes in the fixes for the gotchas above (Java-8-only APIs, stale-session close, and correct InvoiceSignatureHelper signing). This is the exact code proven live against prod for an ИП — read (signed session → queryInvoice) and a real issued invoice (syncInvoice). Layout:
scripts/esf/esf.py— the CLI wrapper (below)scripts/esf/client/EsfClient.java— the CXF + WSS4J + Kalkan client: signed session,queryInvoice,get/errors/delete-failed,validate, and signedsyncInvoice(below)scripts/esf/credentials/config.json— filled by the user (above)scripts/esf/sdk/…— the unpacked SDK
esf.py auto-detects JDK 8, builds the classpath from every jar under sdk/, compiles the client once (skips recompile when the classes already exist), enforces the ≤1-quarter window, and refuses to submit without --confirm-submit. The client closes any stale ЭСФ session before opening a new one (ЭСФ allows only one open session per user) and closes on exit.
Reading invoices — after the SDK is unpacked and config.json is filled:
python3 scripts/esf/esf.py check # config + SDK jars + Java 8 all OK?
python3 scripts/esf/esf.py list --direction OUTBOUND --from 2026-07-01 --to 2026-07-31 # OUTBOUND=issued, INBOUND=received; window ≤ 1 quarter
python3 scripts/esf/esf.py get --id <INVOICE_ID> --out scripts/esf/outbox/<INVOICE_ID>.xml # one invoice body + status
Issuing an invoice — the first-try-safe sequence (accepted is not registered — always verify):
python3 scripts/esf/esf.py prepare-invoice invoice.json --out scripts/esf/outbox/invoice.xml # local review only, no submit
python3 scripts/esf/esf.py validate scripts/esf/outbox/invoiceContainer.xml # parses as a real ЭСФ invoiceContainer
python3 scripts/esf/esf.py submit scripts/esf/outbox/invoiceContainer.xml --confirm-submit # signs + uploads → returns {accepted:[{id,...}]}
# VERIFY (ЭСФ processes async — "accepted" with errors:[] can still end FAILED):
python3 scripts/esf/esf.py get --id <ID> --out scripts/esf/outbox/<ID>.xml # status CREATED = registered, FAILED = rejected
python3 scripts/esf/esf.py errors --id <ID> # reason if FAILED, e.g. SIGNATURE_VERIFICATION_FAILED
python3 scripts/esf/esf.py delete-failed --id <ID> # remove a FAILED record before retrying (avoids a duplicate 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 (workspace root)
scripts/esf/credentials/config.json
scripts/esf/credentials/*.p12
scripts/esf/sdk/
scripts/esf/client/classes/
scripts/esf/outbox/*.signed.xml
reports/esf/
See also: Automations for scheduled invoice pulls or reconciliation, and Deliverables for how reports are produced and shared.