ZuloOne
English
Sign in
Menu

⚖️ 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.

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, then EpAccrued += 100;
  • UaVatPayable — empty: not a ПДВ payer;
  • TaxLedger: TaxBase = 100, TaxAmount = 5 (code UA-EP5, direction OUTPUT, 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 (code UA-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.

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 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:

  • InvoiceType is 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 in EInvoiceKind and stays shared.
  • InvoiceCounter is the chain counter; a regime with no counted chain does not fill it.
  • RejectionReason lives 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 in TaxSubmission.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 (2025 for 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 the vat-UA delivery). 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: TaxLedger would get TaxAmount = 20 on UA-S, even though UaVatPayable is empty;
  • with the mapping: TaxLedger gets a base of 100 on UA-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. PayrollAccrualLines has 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

Where next#

Was this page helpful?