⚖️ The Ukrainian localization
Updated
The LocalizationUkraine country package (the Customization layer). This is
Ukrainian law and services, not a "VAT module". Sales, HR and Tax know
nothing about Ukraine: the package is added on top through extensions, registers
and outbound channels (M.E.Doc, PrivatBank). There is no Diia channel.
← Localizations · Product direction · Global Core For the user: how to switch it on · for the developer: how it is built.
Live setup: M.E.Doc · the ЄРПН tax invoice · PrivatBank Autoclient. Diia is not in the delivery — why it is separate.
The package's perimeter#
| Contour | Today |
|---|---|
| VAT (ПДВ), the single tax (єдиний податок), a legal entity's regime, input VAT credit (вхідний кредит) | in the package, the table below |
| ПДФО and the military levy (військовий збір) | in the package (UaPayrollLevy) |
| ЄСВ | the HR kernel (SocialInsurance): the rates live in the HR settings, there is no Ukrainian object |
| Exporting the return as a file | in the package (UaTaxFilingExport) — a neutral file, not a cabinet |
| Tax invoice (податкова накладна) / adjustment calculation (розрахунок коригування) | in the package: the TaxDocument envelope, M.E.Doc MakeDoc/ToGov/GetDocInfo (erpn-send / erpn-gov / erpn-receipt). The XML and the qualified electronic signature (КЕП) belong to M.E.Doc |
| A live M.E.Doc / State Tax Service (ДПС) cabinet | the adapter is in the package. There is no cloud sandbox: REST on the accountant's PC + an agent. How to set it up: M.E.Doc at a customer. There is no State Tax Service (ДПС) cabinet |
| Diia | no. Sign-in is platform identity, not the package. There is no signing or sharing channel and no partner API: Diia |
| PrivatBank (statements / payments) | Autoclient v3 statement on acp.privatbank.ua. There is no Autoclient sandbox. How to set it up: PrivatBank at a customer. No outgoing payments |
| Software cash register (ПРРО) / fiscal receipt | the 80 mm tape mock-up UaFiscalReceiptSample prints a sample receipt. There is no registrar, shift or fiscalization |
A live connection to a bank or a state portal lives in this package on the same host as Fatoora does for KSA: a script does not open HTTP. Neither M.E.Doc nor Autoclient has a cloud sandbox — setup happens at a customer: M.E.Doc, PrivatBank.
What is in the package today#
| Part | Object |
|---|---|
| Settings | LocalizationUkraineSettings (the single tax codes, the ExemptVatCode exemption code, the e-invoice flag) |
| The bank | UaBankConnection — address, credential name, IBAN. Channel pb-statements (Autoclient GET). Register UaBankStatement. Service IUaBank. Job PullUaBankStatements |
| A legal entity's regime | the UaTaxRegime field on LegalEntity (an extension of the package) |
| The goods code | the UktzedCode field on Item (UKTZED code, 10 characters, an extension of the package) — the УКТЗЕД code the tax invoice prints per line. Filled by hand or by the 1C migration (КодУКТВЭД); nothing validates it yet |
| The accrual register | UaVatPayable (customer × outlet × contract) |
| The first-event register | UaVatFirstEvent (legal entity × customer × contract): Shipped / Paid / Accrued / EpAccrued |
| The invoice | UaVatTx on Issued — writes +Shipped, plus ShippedCoded / ShippedCodedVat (base and VAT of lines that name their own VAT rate); computes no tax itself |
| The payment | UaPaymentFirstEventTx on Paid — writes +Paid (gross) |
| The credit note | UaCreditVatTx on Posted — writes −Shipped |
| The coordinate guard | UaPaymentEntityGuardEventHandler — checks the payment's legal entity against the contract's |
| The accrual | the UaVatFirstEvent driver — one for every document, branching on the regime |
| The service | UaFirstEvent — the signed increase of max(Shipped, Paid) over Accrued, and the cash increase over EpAccrued; SyncExemptMappingAsync sets and removes the TaxMapping |
| The receipt | UaPurchaseReceiptTx on Posted — writes +Received (net), plus ReceivedCoded / ReceivedCodedVat for lines with their own VAT rate |
| The supplier payment | UaVendorPaymentTx on Paid — writes +PaidOut (gross) |
| The purchase credit note | UaPurchaseCreditTx on Posted — writes −Received |
| The input register | UaPurchaseFirstEvent (legal entity × supplier): Received / PaidOut / Credited |
| The credit register | UaVatCredit |
| The export | UaExportTaxReturn → UaTaxFiling puts a neutral file into UaTaxFilingExport |
| Payroll withholdings | UaPayrollLevyEventHandler on PayrollAccrual — computes ПДФО and the military levy and reduces the debt to the employee |
| The withholding register | UaPayrollLevy (legal entity × employee × tax code): Base / Amount / PaidBase / Transferred |
| Remittance to the budget | document UaTaxRemittance (Draft → Paid): writes Transferred |
| Payroll payment | UaPayrollPaymentLevyEventHandler on PayrollPayment Paid — writes PaidBase |
| The withholding service | UaPayrollLevies — the rate on the accrual date, an uncapped base |
| The tax invoice service | UaEInvoice — a TaxDocument envelope out of the invoice and the credit note, MakeDoc → ToGov → GetDocInfo |
| The seed | DataPackages/vat-UA.json — ДПС, UA, the VAT-UA tax (20% / 7% / zero, codes UA-S/UA-7/UA-Z/UA-ZX/UA-E), the EP-UA tax (3% / 5% / 15% plus double 6% / 10%, codes UA-EP3/UA-EP5/UA-EP15/UA-EP6/UA-EP10), and the PDFO-UA (18%, code UA-PDFO) and VZ-UA (1.5% until 2024-12-01, 5% after, code UA-VZ) taxes |
| Geography | DataPackages/cities-UA.json — 27 regions (24 oblasts, Crimea, Kyiv City, Sevastopol) and 30 cities (oblast centres plus Kryvyi Rih, Mariupol, Bila Tserkva, Kremenchuk). Names are English plus Name_uk / Name_ru / Name_ar. The ISO2 installer applies this pack together with vat-UA for a Ukrainian legal entity only; a Saudi legal entity gets cities-SA from LocalizationSaudiArabia instead |
The register is not called VatPayable: that is the KSA package's name. Two
packages on one stand do not share a physical table.
Excess over the limit: the rate depends on the legal form#
The annual income limit (ПКУ 291.4) is dated in UaSingleTaxLimit. The rate for
the excess above the limit is decided by the payer's legal form, not by the
regime:
| Who | Rate | Where it lands in declarations |
|---|---|---|
| FOP (ФОП) | 15% (UA-EP15) |
row 07 of form F0103309 — its label is за ставкою 15 % |
| Legal entity | double: 6% in the 3% regime, 10% in the 5% regime (UA-EP6 / UA-EP10) |
row 2 of form J0103509 |
The flag is LegalEntity.IsLegalPerson (an extension field, Legal person).
The double-rate codes live in LocalizationUkraineSettings.SingleTaxCodeDouble3
and SingleTaxCodeDouble5; they are filled from the delivery like the others.
An empty flag means ФОП. Optional Boolean is generated non-nullable, so
a legal entity with no explicit choice is treated as ФОП. The field is named
IsLegalPerson, not IsSoleProprietor: the reverse name would silently change
the rate for everyone who never filled it. An LLC (ТОВ) on simplified tax must
use the double-rate codes — UA-EP15 does not land in row 2 of form J0103509.
If the double-rate code is empty, the excess is NOT separated into its own line and stays in the ordinary-rate part. Falling back to 15% is forbidden: that rate does not fit row 2, and the result would be worse than doing nothing.
Coverage — UkraineSingleTaxLimitTest: a legal entity pays the double rate, and
an empty flag keeps 15%.
The tax invoice in ЄРПН#
The contour is switched on under Dictionaries → Ukrainian settings:
| Field | What it means | Empty |
|---|---|---|
| E-invoice (tax invoice) enabled | the switch for the whole contour | no tax invoices are created |
| Tax invoice form code | the ДПС form code, J1201010 (a legal entity) or F1201010 (a ФОП) for instance |
no tax invoices are created: we do not invent a form number |
| Tax invoice adjustment form code | a different form, J12012010 / F12012010 for instance |
a credit note produces no envelope |
An issued invoice from a ПДВ payer → a TaxDocument envelope in Issued
carrying the array of MakeDoc cells (ПН: FIRM_NAME/TAB1_A13; РК:
CORRCMPL/TAB1_A3). M.E.Doc assembles the XML, not us.
A successful send means "accepted for processing", not registration: the
envelope stays Issued until a ДПС receipt closes it as Cleared or Rejected. The
PullUaTaxInvoiceReceipts job collects receipts every 7 minutes. The Ask the
operator about this invoice button on the envelope asks again; an InDoubt document
cannot be submitted a second time — that would be a second registration.
A worked boundary: an invoice for ₴1,200 with ₴200 of ПДВ went to the operator and came back "accepted". The ПДВ of ₴200 is already in the books (the first event). There is no tax invoice in ЄРПН yet. Move the envelope to Cleared at that moment and the system shows a registered tax invoice the ДПС has never seen.
A 5% simplified-regime taxpayer (спрощенець) without ПДВ creates no tax invoice. Neither does a credit note with no original invoice: the ДПС will not accept an adjustment calculation with no reference.
There is no live ДПС cabinet in the delivery. The operator's address is a
TaxAuthorityConnection row on the legal entity; without it the envelope stays
Issued with its payload filled in. How to set up M.E.Doc on the accountant's PC
and check MakeDoc without ToGov —
M.E.Doc at a customer. The bank token does not go on that
row — PrivatBank.
This gives an honest boundary: the exchange with the operator itself is NOT
covered by tests. Not by oversight — there is nothing to cover it with: the
queue returns only completed messages, a script cannot mark a message
completed, and there is nowhere to get the operator's answer from.
UkraineTaxInvoiceTest says so in its own header. A test pretending the
exchange happened would be green on a broken contour too, which is worse than
no test. Everything up to the send is checked (the envelope, the gates,
idempotency) and everything after the answer (TaxDocumentOutcomeTest); what is
not checked is the middle, which we do not have.
For the user: the tax invoice. For the developer: ЄРПН.
Tax regimes: an LLC (ТОВ) and several FOPs (ФОП) in one database#
A client keeps an LLC (ТОВ) and several FOPs (ФОП) in one system, and they are computed differently. The regime is a property of the legal entity, not of the system, which is why it lives on the legal entity.
Where it is switched on#
Dictionaries → Legal entities → pick one → the "Tax regime (Ukraine)" field. The field is optional and has four values:
| Value | ПДВ | Single tax |
|---|---|---|
| VAT payer (general system) — the default | on max(shipped, paid) |
none |
| Simplified 3% with VAT | on max(shipped, paid) |
3% of income EXCLUDING ПДВ |
| Simplified 5% without VAT | not accrued at all | 5% of the whole amount received |
| General system, not a VAT payer | none | none |
The default is "VAT payer", and that is deliberate. The field is non-nullable: every legal entity created before regimes existed arrives holding zero. Zero has to mean the behaviour that was there before, or the upgrade would quietly stop accruing ПДВ for everyone. Put "simplified-regime taxpayer" in that slot and the upgrade becomes a silent tax accident.
The single tax rates come from the contour, not from code#
Dictionaries → Ukrainian settings → "Single tax code, 3% (with VAT)" and
"Single tax code, 5% (without VAT)". What goes in the fields is a Code from
the TaxCode dictionary; the shipped values are UA-EP3 and UA-EP5. The rate
itself sits in TaxRate and is chosen as of the movement's date, so a
change of rate in law stays a new dated row rather than an edit to code.
An empty field, or no such code on the stand, and the single tax is simply not accrued — exactly as ПДВ is not without a configured contour. The refusal is silent: nothing appears in the log.
A worked example#
A third-group ФОП at 5%, not a ПДВ payer. A customer pays ₴100:
UaVatFirstEvent:Paid += 100, thenEpAccrued += 100;UaVatPayable— empty: not a ПДВ payer;TaxLedger:TaxBase = 100,TaxAmount = 5(codeUA-EP5, directionOUTPUT, that ФОП's legal entity).
The same ФОП on 3% with ПДВ receives ₴120:
- the ПДВ base = 120 / 1.20 = 100,
UaVatPayable = 20; - the ЄП base is 100 too, not 120: the single tax is taken on income excluding ПДВ;
TaxLedger:TaxAmount = 3(codeUA-EP3).
And a shipment to that same ФОП on credit for ₴100 produces nothing: the single
tax is a cash tax. If it used max(Shipped, Paid) like ПДВ does, a
simplified-regime taxpayer would pay tax on a delivery nobody has paid for.
Why ЄП uses the existing registers#
Paid already is the cash turnover a simplified-regime taxpayer pays on. The
counter of what has been taxed is EpAccrued. A separate
counter rather than the shared Accrued, because for a 3% simplified-regime taxpayer the two
taxes' bases diverge: ЄП is taken on income excluding ПДВ, and ПДВ on the
first event. One counter for both would understate first one tax and then the
other.
The tax itself goes into the shared Tax.TaxLedger rather than into
UaVatPayable: the ledger is already sliced by tax code × direction × legal
entity — precisely how ЄП is declared. UaVatPayable is sliced by customers and
trading outlets, which is a meaningless breakdown for a turnover tax.
Why the legal entity is a dimension#
The regime is read off the coordinate, and a totals driver sees coordinates
only, so the legal entity is in UaVatFirstEvent's key.
On the invoice, a handler stamps the seller — from the contract, and if that is
empty, from the shipping cell. A payment without the same stamp would let the
header keep whichever legal entity the operator selected from the full list. In
a system with an LLC and two FOPs, Shipped would land on one coordinate while
Paid landed on another, max(Shipped, Paid) would be computed in each from
zero, and the tax would accrue twice: ₴40 on a single delivery of 100. No
error, no warning — each figure looks right on its own.
Hence UaPaymentEntityGuardEventHandler on posting a payment:
- the header's legal entity contradicts the line's contract → posting is refused;
- the header's legal entity is empty → it is taken from the contract. Empty is not a disagreement, it is absent data that the contract happens to know; there is nothing to refuse;
- one payment carries lines on contracts of different legal entities while the header is empty → refused: there is nothing to take.
The ІПН lives on the legal entity, not in the settings#
The LocalizationUkraineSettings.VatRegistrationNumber field is obsolete and
read by nothing. The ІПН is a property of a legal entity: one database holds
an LLC and several FOPs, and each has its own number. The active field is
LegalEntity.TaxRegistrationNumber ("Податковий номер"), a field that has
existed for a long time. The obsolete settings field remains in metadata until
it is removed through a delete-impact run and the API; deleting metadata from
files alone does not propagate.
A tax of its own rather than the shared one, and why#
Each country holds its own tax: VAT-UA (ДПС, jurisdiction UA) and VAT-SA
(ZATCA, jurisdiction SA). One shared VAT cannot carry 15%, 20% and 7% in force
on the same date — ITaxService.ResolveRateAsync refuses to compute when more
than one rate fits. The shared VAT remains a template for a stand with no
country packages.
Within a country the rates are distinguished by category
(TaxRate.TaxCategory): 20% in STANDARD, 7% in
REDUCED. The date still picks the version of a rate WITHIN a category — a
change in law stays a new dated row rather than an edit to every code. A rate
with no category is the tax's default band, so single-rate taxes (the KSA
history of 5%→15%) work unchanged.
The zero and exempt treatments (ZERO_RATED, EXEMPT, OUT_OF_SCOPE) give 0
without consulting the rate table: that is a zero by law, not by
configuration. They do not fall through to the standard rate. REVERSE_CHARGE
is deliberately not on that list — there the rate matters, for the buyer's
self-assessment.
The 14% rate is not in the seed: its status after the 2022 changes is not confirmed by a source, and a tax rate may not be invented.
The TaxDocument envelope is decoupled from ZATCA#
The shared submission envelope is country-neutral:
InvoiceTypeis optional. It is the document class and belongs to the country: a regime with no such notion leaves it empty. The kind of document (invoice / credit note / debit note) lives inEInvoiceKindand stays shared.InvoiceCounteris the chain counter; a regime with no counted chain does not fill it.RejectionReasonlives on the envelope. Ukraine needs it directly: registration suspension is not a refusal but a suspension with a reason, and the reason belongs on the envelope itself, not only inTaxSubmission.ResponseMessage.
What is deliberately not renamed. The Cleared and Reported subtypes are
the ZATCA split between "clearance before handing it to the buyer" and
"reporting after the fact". Most regimes do not have it, and Ukraine has a state
of its own in its place — "suspended". This stays a known limitation.
The first-event rule (перша подія) — why it is not the document that computes the tax#
A Ukrainian liability arises on the date of whichever event happened first: the shipment or the receipt of money (ПКУ 187.1). This is where Ukraine differs from Saudi Arabia, where the tax is tied to the invoice. A prepayment creates a liability by itself, and a subsequent shipment within its limits does not.
Matching a particular payment to a particular shipment cannot be done
honestly: a CustomerPayment line carries the customer, the contract and the
amount, but no reference to an order or an invoice. So two totals accumulate per
contract, and what is taxed is max(Shipped, Paid) — the larger one is exactly
"the events that have already happened". Accrued remembers what has already been
taxed, and each event takes tax only on the increase. All four cases fall
out of that rule on their own, without a single if.
Documents therefore do not compute tax at all — they only report an event. The rule lives in the totals driver, as the cost write-off does in Costing: a first event is created by a shipment, by a payment and by a credit note alike, and a copy of the rule in each transaction script is a way of making them diverge.
Three things that are easy to get wrong#
The register's key is dimensions, not analytics. TransactionBase knows
coordinates only; a posting has no access to analytics whatsoever. Declare the
key as an analytic and the driver runs for nothing: the movements are written,
the tax is not accrued, and the log holds neither an error nor a hint. That cost
a debugging round of its own.
Shipped is net, Paid is gross. Money arrives with tax on it and goods
are valued without it: a prepayment of 120 at 20% closes a base of 100, not 120.
The driver brings them to the same base, because doing so needs the rate and the
rate resolves asynchronously — it cannot be read from a synchronous
GetTransactions.
The increase is signed. A credit note reduces Shipped, the target drops
below what has been taxed, and the tax has to be released. Clamping the minus at
zero would leave the max threshold too high, and what broke would not be the credit
note but the document after it.
What a credit note deliberately does not do#
It does not release the tax if the money has not been returned. Goods returned while the advance is retained leave the ПДВ accrued: the first-event rule was the payment, and returning the goods does not undo it. The figure that agrees with the goods disagrees with the law here — that is a test of its own.
Input VAT credit on purchases (ПКУ 198.2)#
The same rule as on sales, from the other side: the right to credit input ПДВ arises on the date of whichever event happened first — money paid out to the supplier, or goods received. A prepayment to a supplier gives the credit at once; a delivery within its limits does not.
Documents do not compute the tax. A receipt writes Received (net), a supplier
payment writes PaidOut (gross), and a credit note writes a minus into
Received. The crediting is done by the UaPurchaseFirstEvent driver:
max(Received, PaidOut) minus what has already been credited. The credit
register is UaVatCredit (legal entity × supplier).
Purchasing has no supply contracts, so the key has no third dimension: every purchase from one supplier by one legal entity is one basket.
The tax credit is a right of a ПДВ payer. A 5% simplified-regime taxpayer and a non-payer on
the general system do not credit input tax: for them it is part of the cost of
the goods. Credited stays zero for such entities, or a move to the general
system would see an "already credited" base that never existed.
A credit note moves the base rather than posting a minus into
UaVatCredit. Otherwise Received and Credited would stay overstated, and
the next receipt from the same supplier would give no credit until it had
covered the overstated level. Goods returned while the advance is retained do not
remove the credit: the first-event rule was the payment.
There is no single tax on purchases and there cannot be: it is computed on income, and a purchase creates no income.
Verified by#
UkraineVatFlowTest (9 cases): 20% on 100 is 20; with no contour there are no
postings; a backdated document takes the historical rate; a credit note zeroes
the register; the outlets of one customer are not mixed. Plus the first-event rule
proper: a prepayment of 120 accrues 20 at once and the shipment it covers does
not accrue again; a partial prepayment of 60 gives 10 and the shipment brings it
up to 20 rather than adding a second 20; a payment after a shipment is a second
event; goods returned while the advance is retained do not release the tax.
UkrainePurchaseVatTest (9 cases) — the mirror on purchases: a receipt with no
payment gives a credit of 20 on a base of 100; a prepayment of 120 gives the
credit before the delivery, and the delivery within its limits does not give it
a second time; a payment after the delivery does not repeat the credit; a
partial advance of 60 gives 10 and the delivery brings it to 20; a simplified-regime taxpayer
without ПДВ writes the events but leaves the credit and Credited at zero; with
no contour there is no credit. Plus the credit note: it removes the credit by
moving Received; it does not remove it while the advance is retained; and the
next receipt after the note gives a credit again — otherwise the max bar would
be stuck.
UkraineLineTaxCodeTest (3 cases) — line VAT rates: a realization with a 7 %
line of 100 and an uncoded line of 100 gives a receivable of 227 with 7 and 20
in TaxLedger under their own codes; as the first event it accrues 27 to
UaVatPayable on a base of 200, not 40; the same two lines on a receipt give
an input credit of 27.
UkraineSeededRatesTest — walks the shipped seeds rather than a contour the
test assembles itself: UA-S = 0.20 and UA-7 = 0.07 coexist; UA-Z and
UA-E give 0; before 2014-04-01 UA-7 has no rate (null, not 0); the Ukrainian
codes point to VAT-UA; rates of one category do not overlap in their dates.
Plus the single tax: UA-EP3 = 0.03 and UA-EP5 = 0.05 resolve, and each ЄП
rate sits in its own category — two rates of one tax without categories
resolve as "more than one rate is in force", the driver catches that refusal,
and ЄП is silently not accrued.
The seed installs itself when a legal entity has the country UA
(ITaxPackInstaller / ApplyOrg).
UkraineTaxRegimeTest (8 cases) — the regimes and the coordinate guard: a ПДВ
payer accrues ПДВ and does not accrue ЄП; a 5% simplified-regime taxpayer does not accrue
ПДВ at all and pays 5% on the whole amount received; a shipment on credit costs
them nothing while a payment does; a 3% simplified-regime taxpayer pays both taxes, and the ЄП
base is 100 on 120 received; a general-tax-system non-VAT payer pays nothing; a second payment
is taxed only on its own increase (300 → 15, not 20); a payment with the wrong
legal entity is refused and the ПДВ stays 20 rather than doubling; a payment
with no legal entity takes it from the contract.
Almost every case checks two figures — that the tax that should be accrued was, and that the adjacent one was not. Without the second half, "ЄП = 0" on a ПДВ payer looks correct even when ЄП is not computed for anybody at all.
UkraineExemptMappingTest — one end-to-end case: changing the regime creates a
TaxMapping to the exemption code by itself, returning to a payer removes the
row, the package does not overwrite another source's live row, and with no code
in the settings nothing is created. The document path — "the invoice picked up
the mapping" — is closed by TaxDeterminationFromDocumentTest.
UkraineSingleTaxLimitTest (4 cases) — the income limit: under the threshold,
the ordinary rate; a payment crossing the threshold, on two codes; above the
limit, 15% in full; with no limit set, the behaviour as before.
UkraineForcedRegimeChangeTest (5 cases) — the transition date after the
quarter; exactly one journal row; before the date the job leaves the regime
alone; a 5% simplified-regime taxpayer becomes a non-payer on the general system; a 3%
simplified-regime taxpayer stays a ПДВ payer.
UkraineFilingExportTest (3 cases) — the export carries the ІПН, the period and
the totals by cell; an unmapped code shows as not mapped (не зіставлено); a repeat for
the same period replaces rather than multiplies.
The export for filing#
Accountants file two ways: a ТОВ through M.E.Doc, FOPs through a bank cabinet — and part of the figures are typed in by hand. The print form answers "show me", the file answers "carry this across without recomputing anything". Both are needed.
The Export for filing button on TaxReturn (draft and filed) calls
UaTaxFiling.ExportAsync. The declaration type is taken from
LocalizationUkraineSettings.VatReturnType — the same code as in
TaxReportMapping.ReturnType, or the cells and the file will diverge. Empty
means J0200126 (VAT declaration, Ministry of Finance order 2024-08-09 № 400).
The vat-UA delivery seeds mapping of UA-S/UA-7/UA-E to rows 1.1 / 1.2 /
5 / 10.1 / 10.2. Rows 9 and 17 are the order's formulas, 18 is payable, 19 is
negative. Row 20.2 is document UaVatRefund (register UaVatRefundClaim), not
above row 19. Test: UkraineVatDeclarationFilingTest.
The Export single tax (J0103509) button is a separate
UaExportSingleTax command so the VAT default does not change. The vat-UA
delivery seeds UA-EP3/UA-EP5 to column 3/4 row 1 and UA-EP6/UA-EP10 to
row 2. Non-cash settlements are UA-EPB3/UA-EPB5 (row 3), forbidden activities
are UA-EPF3/UA-EPF5 (row 4); the same double rates 6% / 10%, separate codes
so they do not mix with excess above the limit. The period runs from 1 January
cumulatively. Row 9 is the ledger through the end of the previous quarter. The
МПЗ appendix is dictionary UaLandPlot. UA-EP15 (15%) is not the double rate
for a legal entity — it is row 07 of ФОП form F0103309. Test:
UkraineSingleTaxDeclarationFilingTest.
The Export FOP single tax (F0103309) button is command
UaExportFopSingleTax. The delivery seeds UA-EP3/UA-EP5/UA-EP15 to rows
05/06/07. Row 23 = 1% of income, not UA-VZ. F0133109 is document
UaFopEsvAccrual. F0133209 / 14.2 are the same land plots. Test:
UkraineFopSingleTaxDeclarationFilingTest.
A neutral file (not a ДПС blank) remains when another type is set in settings.
The rows are aggregated by cell; an empty cell is not dropped but written as
(не зіставлено) — otherwise the mistake would be noticed after filing. A
repeat for the same legal entity × period × kind replaces the previous
export.
Find the completed file at Ukraine → Dictionaries → Tax filing export.
The annual income limit: 15% on the excess#
A third-group simplified-regime taxpayer is capped by annual income (ПКУ 291.4). Going over the limit is not forbidden: the money arrived, and there is nothing to refuse income for. The law taxes the amount above the limit at 15% (ПКУ 293.4) and moves the entity to the general system from the start of the next quarter. The first half of that, the money, is computed automatically.
Where it is set#
Dictionaries → Single tax revenue limit → create:
Code= the year (2025for instance),Limit= the amount,Valid from= 1 January,Valid to= 31 December.
The limit is dated because it changes every year along with the minimum wage: it is data, not a number in code. An empty dictionary, or a limit of zero, and the excess is not computed at all — the whole income goes at the ordinary rate.
Dictionaries → Ukrainian settings → "Single tax code, 15% (over the limit)" — the shipped value is
UA-EP15. If it is empty, the excess is not separated out.
A worked example#
A ФОП on 5%, limit ₴1,000. A payment of ₴800 arrives — all of it under
the limit: TaxLedger gets a base of 800 on the 5% code, tax 40.
A second payment of ₴500 arrives. It crosses the threshold, and one payment creates two rows:
| Code | Base | Tax |
|---|---|---|
| 5% (ordinary) | 200 — takes the limit up to 1,000 | 10 |
| 15% (the excess) | 300 — above the limit | 45 |
For the year: on the ordinary code a base of 1,000 and tax of 50, on the excess code a base of 300 and tax of 45. The next payment goes into the excess in full.
Different codes on purpose: let the ordinary rate merge with the penalty rate into a single row and there would be nothing left to take them apart with in the return.
The move to the general system happens on a QUARTER boundary#
Exceed the limit and, from the first day of the month following the quarter of the excess, a simplified-regime taxpayer moves to the general system (ПКУ 293.8). Not from the date of the excess and not from the next month: exceed it on 5 May and you move on 1 July; exceed it on 20 December and you move on 1 January.
The decision is taken at the moment of the excess and takes effect weeks later, so:
- the driver writes a row into the Dictionaries → Forced regime change
journal (
UaRegimeChange): the legal entity, from which regime to which, the date of the excess, the date of the move; - the ApplyUaRegimeChanges job (daily, 03:00) switches the regime when the date arrives and fills Applied on.
The journal is an audit trail, not a housekeeping table: an accountant will ask why a ФОП stopped being a simplified-regime taxpayer, and the answer has to be in the system with dates rather than derived afresh from movements.
Where it moves them depends on ПДВ. A 3% simplified-regime taxpayer was a ПДВ payer and stays one on the general system; a 5% simplified-regime taxpayer was not and does not become one. A registration as a payer is not cancelled by the move.
Repeated excesses within the same quarter do not multiply rows, and a repeated run of the job does not move anyone twice: either would be duplicate audit noise.
What counts as "already accumulated"#
The EpAccrued movements of this calendar year for the legal entity, not the
register's balance: the balance accumulates over all history while the limit is
annual.
A simplified-regime taxpayer and TaxLedger: the mapping creates itself#
The package's registers branch on the regime by themselves — UaVatPayable
being empty for a non-payer of ПДВ is correct. The tax calculation that posting
an invoice starts through the shared Sales → Tax path knows nothing about
Ukrainian regimes and, with no hint, takes TaxSettings.DefaultTaxCode. So a
ФОП on 5% would end up with ПДВ in the ledger they report from, which they do
not owe.
It is cured by the ordinary TaxMapping, with no branch in Tax. A change of
regime on a legal entity creates and removes the row by itself:
Dictionaries → Ukrainian settings → "Exempt VAT code (no-VAT regimes)" =
UA-E(from thevat-UAdelivery). If it is empty, no row is created, and the ledger goes back to the default.
When a regime does not pay ПДВ (спрощена 5% or загальна without ПДВ), the
package creates a mapping with SourceType = LegalEntity and TaxCode = that
code. When the regime is a payer again it removes it (IsDisabled).
Determination runs rule → mapping → default, selects UA-E, and the invoice
posts at zero — in UaVatPayable as much as in TaxLedger.
A worked example#
A ФОП on 5%, the stand's default being UA-S at 20%. A shipment on a base of
₴100:
- with no mapping:
TaxLedgerwould getTaxAmount = 20onUA-S, even thoughUaVatPayableis empty; - with the mapping:
TaxLedgergets a base of 100 onUA-E, tax 0.
Return the legal entity to "VAT payer" and issue another invoice for ₴100 — tax of 20 by default, and the exemption row is no longer live.
The package does not overwrite a row owned by another source on the same legal entity: the windows of one source cannot overlap. A credit note reverses on the same code the invoice accrued on — the determination context is on all five documents.
Payroll: ЄСВ, ПДФО and the military levy#
There are three payroll-related payments, and they are different by nature — which decides where each of them lives.
| Payment | Who pays | Base | Where it is computed |
|---|---|---|---|
| ЄСВ 22% | the employer, on top of the wage | capped: 15 minimum wages | the HR social insurance contour, with no Ukrainian code at all |
| ПДФО 18% | withheld from the employee | no cap | LocalizationUkraine |
| ВЗ 5% | withheld from the employee | no cap | LocalizationUkraine |
ЄСВ is exactly what SocialInsurance in HR already models: an employer's
contribution to a fund, with a maximum base. No object was written for it — it is
configured with rates, like any other country.
Where it is set#
ЄСВ — Dictionaries → HR settings:
| Field | The value for Ukraine |
|---|---|
| "Social insurance rate, employee" | 0 — in Ukraine ЄСВ is not withheld from the employee |
| "Social insurance rate, employer" | 0.22 |
| "Social insurance rate, employer (foreign staff)" | 0.22 — citizenship does not affect ЄСВ |
| "Social insurance base cap" | 15 minimum wages (at a minimum wage of ₴8,000 — ₴120,000) |
Zero in the base cap means "no cap", not "a base of zero".
ПДФО and ВЗ — Dictionaries → Ukrainian settings:
| Field | The shipped value |
|---|---|
| "Personal income tax code (payroll)" | UA-PDFO |
| "Military levy code (payroll)" | UA-VZ |
If both fields are empty, there are no withholdings at all. This is the only switch: the package does not guess from the legal entity's country, because several localizations live on one stand.
A worked example#
₴10,000 accrued to one employee:
| Line | Amount |
|---|---|
| Accrued | 10,000 |
| ПДФО 18% | −1,800 |
| ВЗ 5% | −500 |
| Payable | 7,700 |
| ЄСВ 22% (on top, to the fund) | 2,200 |
The employer's cost is ₴12,200 and the employee receives ₴7,700.
The ЄСВ cap does not extend to ПДФО and ВЗ#
At a wage of ₴150,000 and a cap of ₴120,000:
- ЄСВ is computed on 120,000 → ₴26,400;
- ПДФО and ВЗ on the whole 150,000 → ₴27,000 and ₴7,500.
The temptation to take the ready-made social contribution base for ПДФО (it is
already computed and sitting on the accrual line) would understate the
withholdings of everyone earning above the cap. The DeductionsIgnoreTheEsvCeiling
test exists precisely against that.
The ВЗ rate is taken on the accrual date#
The military levy was 1.5% and became 5% on 2024-12-01 — both rates are in the delivery with closed windows. A recalculation for November 2024 run today takes 1.5%: otherwise people would be charged for something that did not exist in that period.
Why the withholdings are visible twice#
UaPayrollLevy is sliced by employee — that is for 4DF, where every row is
a particular person. The model ships the Ukraine payroll levies by employee
and tax code layout (legal entity × employee × code: base, withheld,
transferred to the budget). This is a register slice, not a ДПС blank.
TaxLedger is sliced by legal entity and tax code — that is money to pay, and it
is where the return is assembled from by the same machinery that handles ПДВ and
single tax. One slice is not enough: you cannot file 4ДФ from the ledger,
and you cannot pay from the per-person register.
Why HR exposes Withheld#
Voiding an accrual checks whether the debt to the employee has been settled.
The Withheld resource on PayrollLiability means "the debt was reduced
by a withholding", as distinct from "the debt was settled by a payment". Without
it, a second withholder would make every Ukrainian accrual unvoidable — with a
message about a payment that never happened. HR does not know about ПДФО and the
military levy and should not — a withholder declares itself through that
resource.
What is deliberately not here#
The working tables remain: VAT and single tax are still CSV (there is no XSD
for these forms). The payroll J05 family builds an unsigned DECLAR in the
DeclarXml field — import into M.E.Doc, not cabinet container № 499. Tax office
codes live on the legal entity (DpsRegionCode / DpsOfficeCode) and land in
the CSV header and XML head. Paid and transferred values come from the process:
payroll payment and document UaTaxRemittance. Official column names for
J0500111 / 4ДФ / Д1 are already written — see below.
What exists: a withholding workpaper for the declaration period. The
Export payroll levies (4DF workpaper) button and UaLevyWorkpaperZr print on
TaxReturn. The file is in UaTaxFilingExport with type UA-LEVY, explicitly
labelled "not a ДПС 4ДФ blank". A repeat replaces this row and does not touch
VAT export. Test: UkrainePayrollFilingTest.
There is also a workpaper for the unified calculation: Export quarterly payroll (ЄСВ+ПДФО+ВЗ workpaper) and UaQuarterlyWorkpaperZr. File type
UA-QPR: ЄСВ from SocialInsurance (through division → legal entity) plus
ПДФО/ВЗ from UaPayrollLevy. It is explicitly labelled "not a ДПС blank". A
repeat replaces this row and does not touch UA-LEVY. Test:
UkraineQuarterlyFilingTest.
Official blanks from 2026-08-01 (Ministry of Finance order № 243):
Export DPS payroll forms (J0500111) writes J0500111 / J0510411 / J0510111
and, when the month has events, J0510511 (D5: HireDate / FireDate /
DpsTransferDate / TimeOff 4–6, profession code on position, secondary
employment) / J0510611 (D6: special-service code on employee). D1 G12 is days
with TimeOff.Kind=Sick, G13 is DpsUnpaidLeave, G15 is maternity
(DpsPersonCategory=5), G21/G22 are secondary employment and part-time, G24 is
DpsNewWorkplace (not HireDate). G5 is nationality from Nationality, G6S is
sex Ч/Ж from the field, G8=26 for income sign 102, G111–G113 are separate full
name fields. Calculation: row 1.1 is 4ДФ sign 101, 1.2 is 102; R0107 = accrued
ЄСВ (payment to the fund is not a form cell). Rows 2.2 and 2.3 are filled only
when a posted employer contribution is exactly 8.41% or 5.3% of base; any other
rate stays in 2.1. D2 and D3 are not filed by an ordinary employer. Accrued comes
from registers after accrual. Paid is PaidBase after PayrollPayment Paid.
Transferred is Transferred after UaTaxRemittance Paid. The header carries the
legal entity's Код ДПІ. Unsigned DECLAR (order № 729) is written to
DeclarXml next to CSV; there is no container № 499 / КЕП. Test:
UkraineDpsPayrollFilingTest.
Returning to the wrapper#
CSV and unsigned DECLAR (order № 729) are already written. Return to the
cabinet wrapper separately. Do not fill amounts into empty cells.
Already in the file and checked: 8.41% rate row (calculation 2.2), МПЗ land rows
in J0135709 and F0133209, documents for ФОП ЄСВ for self in F0133109.
Open:
- G18 (minimum wage) and D1 accrual type 2/3.
PayrollAccrualLineshas no separate type-13 line or sick-leave slice. G18 without ЄСВ on the difference up to the minimum wage will diverge from G20. - Calculation rows 1.3–1.5 (sick leave and maternity amounts) — the same slice is missing.
- D2 / D3 are not filed by an ordinary employer.
- POST to the cabinet, container № 499, КЕП.
- The 5.3% cell (row 2.3) uses the same
MatchesEsvRate. There is no separate test for it; there is one for 8.41%.
What is deliberately absent#
The vat-UA delivery does not seed a row for the exemption code in the
settings — taxes are seeded, not a single dictionary. While all four codes are
empty in the database and the exemption tax codes exist in metadata, the
first save of the settings fills them in by itself. We
look at the saved row, not at the incoming update payload: on an UPDATE the untouched
fields would look empty and would overwrite a deliberately cleared exemption
code. The delivery does not seed the tax invoice and adjustment calculation form codes
either: the form number depends on the version of the ДПС blank, and it cannot
be invented.
Line VAT rates and the first event#
A realization or a receipt line may name its own VAT rate (TaxCode, 1C: СтавкаНДС). When the shipment (or the receipt) is the first event, each such line is taxed at its own rate on the document date; lines without a rate are taxed at the default VAT rate, as before.
| Realization | Base | VAT |
|---|---|---|
| Line 100, code 7 % | 100 | 7 |
| Line 100, no code (default 20 %) | 100 | 20 |
UaVatPayable |
27, not 200 × 20 % = 40 |
The receipt mirrors it: the same two lines give an input VAT credit (UaVatCredit) of 27. A prepayment that comes before the shipment is taxed at the default rate — there are no lines yet; if it covered part of the shipment, the shipment adds only its share of the line-rate VAT.
What's not there yet#
- Credit notes release VAT at the default rate: their tax calculation and the first event do not split by line code.
- There is no State Tax Service cabinet connector; exports are files or M.E.Doc-facing payloads, not direct cabinet submission.
- There is no Diia channel in the package.
- PrivatBank pulls statements. There is no outgoing bank payment and no statement-to-invoice matching.
- There is no software cash-register fiscalization or fiscal shift.
- Payroll and tax exports do not sign a cabinet container; the qualified electronic signature stays outside ZuloOne.
- Payroll accrual has no separate sick-leave amount; the DPS export keeps accrual type 1 for the shipped payroll flow.
Same topic for other readers: Developer