ZuloOne
English
Sign in
Menu

📑 Ukrainian VAT (ПДВ) — for developers

Updated

← Localizations

Package LocalizationUkraine. User: legal entity regime. Analyst: localization — the perimeter is there too (unified social contribution (ЄСВ), the Unified Register of Tax Invoices (ЄРПН), cabinet, PrivatBank, Diia), not only VAT (ПДВ). Tax invoice: Unified Register of Tax Invoices (ЄРПН).

Why the tax is computed by a driver and not by the document#

The liability date is whichever event comes first: shipment or payment (Tax Code of Ukraine (ПКУ) 187.1). Documents only report (+Shipped / +Paid). The rule is the UaVatFirstEvent totals driver and the IUaFirstEvent service. On purchases the same mirror exists: UaPurchaseFirstEvent / tax credit (податковий кредит, ПКУ 198.2).

On UaVatFirstEvent, the customer and contract are dimensions, not analytics. The driver sees coordinates only; the API rejects a duplicate analytic with the same name (the field is already a column). The outlet is not part of the first-event key: the tx does not write it, and the outlet breakdown lives in UaVatPayable, where the driver puts the Customer / CustomerOutlet / SalesContract analytics. CustomerOutlet is the contract's outlet only when the contract's outlet list (SalesContractOutlet) holds exactly one outlet; a contract for several outlets writes an empty outlet, because a prepayment on it belongs to none of them.

UaTaxRegime is an extension field of LegalEntity. It does not appear in the generated class: read it from the bag, write it with Db.UpdateAsync as the enum number.

Why TaxMapping, not if (regime) in Tax#

Sales → Tax knows nothing about the country, and this is a layer boundary, not an unfinished feature. A branch "if Ukraine and single-tax payer, then zero" would put LocalizationUkraine inside Tax or the reverse. The standard determination path already exists: rule → mapping → DefaultTaxCode.

IUaFirstEvent.SyncExemptMappingAsync sets the LegalEntity → ExemptVatCode mapping when ChargesVat(regime) == false, and disables it (IsDisabled) when it becomes true again. It does not touch another live row on the same source: windows cannot overlap.

It is called from two places, and both are needed:

Who Why the other is not enough
UaLegalEntityRegimeEventHandler.OnAfterSave Legal entity card. SaveRecordAsync raises events.
ApplyDueRegimeChangesAsync after UpdateAsync The job writes the regime as a bag. UpdateAsync may not raise dictionary events, and the mapping would remain from the previous regime.

The exemption code is taken from LocalizationUkraineSettings.ExemptVatCode, not from the literal "UA-E": a test contour and a manual stand without the vat-UA delivery would otherwise silently fail to find the code, and it would look like "the ledger still accrues 20%".

On the form this is the ExemptVat reference (and neighbouring SingleTax*, IncomeTax, MilitaryLevy) to TaxCode. Saving writes the paired *Code row, because the ledger searches by code. An empty reference does not erase the code: on UPDATE the bag contains only touched fields.

The vat-UA seed does not fill empty codes in settings. UA-E / UA-EP3 / UA-EP5 / UA-EP15 are substituted when in the database all four are still empty (LocalizationUkraineSettingsEventHandler). We look at the saved row, not the incoming bag: on UPDATE only touched fields are hydrated, and other codes would look empty. A deliberately cleared ExemptVatCode next to a filled single-tax code is not overwritten by repeated substitution.

What breaks if#

  • You do not call sync after UpdateAsync of the regime — a sole proprietor (ФОП) moved by the job remains with or without UA-E according to the previous regime, not the current one.
  • You hardcode UA-E in the script — a test that created its own exempt code misses the mapping.
  • You overwrite another TaxMapping — an operator who lowered a rate manually loses it on any legal entity card save.

Tests: UkraineExemptMappingTest posts invoices, not only checks a dictionary row. TaxProfileMappingTest passes the context itself and will not see this break.

Payroll withholdings: why an event handler, not a transactional script#

The usual path for posting entries is a tx script: the platform reverses its movements automatically when a document is voided. It did not fit here, and the reason is technical.

GetTransactions is synchronous, while each accrual needs three async reads: settings, tax code and rate as of the date. The regular workaround — reserving the result in a header field from a before-update event — does not work here: withholdings are personal, as many as there are lines, and they do not fit into the header.

The trade-off is explicit: movements written by hand are not reversed by the platform. Removal is written explicitly in OnAfterPost on the transition to Voided.

What is easy to break on removal#

DeleteDocumentMovementsAsync deletes the document's movements by the whole register, not by author. In PayrollLiability, an HR tx script writes with the same document — deleting by document would remove the accrued liability together with the withholding.

Therefore: delete only where we are the only writer (UaPayrollLevy, and nobody except us touches TaxLedger by a payroll document), and return the liability with an opposite movement. The amount to return is read from our own register, not recomputed: the rate may have changed since.

Withheld — contract between HR and any withholder#

PayrollVoidService checks whether the debt was settled by a payment, and used to explain a balance shortage only by EmployeeContribution of the linked social contribution. A second withholder broke this: liab (7700) < line.Amount (10000) − taken (0) → "the debt has already been settled by a payment", even though there was no payment.

The PayrollLiability.Withheld resource separates two meanings that Amount merged into one: "debt reduced by withholding" (can be voided) and "debt settled by payment" (cannot). HR knows nothing about personal income tax (ПДФО) and military levy (військовий збір) — the withholder declares itself as a resource.

When you write a new withholding, fill Withheld with the same amount, otherwise the accrual becomes impossible to void.

Idempotency#

A movement written from OnAfterPost triggers totals recalculation, and that runs the event chain again. The key is the document itself: if it already has movements in UaPayrollLevy, the second pass is not needed. The same key is cleared on voiding, so "removed" and "can accrue again" are one state.

Analytics in TR_ are stored as a set identifier#

QueryMovementsAsync over a register with analytics returns AnalyticSetId, not an Employee column: employee breakdown cannot be parsed from there. In UaPayrollLevy, employee is a dimension, so reading on removal works. This is also why HR reads withheld amounts as a balance (GetBalanceAsync re-aggregates analytics), not as a movement query.

What breaks if#

  • You take the ready ContributoryBase from the social contribution line for personal income tax (ПДФО) — it is cut by the unified social contribution (ЄСВ) cap, and withholdings are understated for everyone above the cap.
  • You take the rate at DateTime.UtcNow instead of the document date — a recalculation for November 2024 takes 5% instead of 1.5%.
  • You collapse personal income tax (ПДФО) and military levy (ВЗ) into one amount — there will be nothing to split it back with after the next rate change, and they go to different budgets.

Tests: UkrainePayrollLevyTest (6). Unified social contribution (ЄСВ) is verified there without a single line of Ukrainian code — only through HRSettings rates.

4DF workpaper — not a statutory form#

There is no income sign in UaPayrollLevy: a zero rate writes no movement, while non-taxable income in 4DF lives exactly in the sign. Accrued/paid are also absent: the register writes accrual, not personal income tax payment to the budget and not payment to the employee.

Therefore TaxReturn holds UaLevyWorkpaperZr + UA-LEVY export, not a State Tax Service form. The file itself says it is not a State Tax Service schema. An invented 101 in print would pass our validation and fail only in the cabinet. Test: UkrainePayrollFilingTest.

Unified-report workpaper — not a State Tax Service form#

There is no official J050010x: appendices, income signs and the State Tax Service XML schema must not be invented. Unified social contribution (ЄСВ) lives in SocialInsurance with analytics Employee and Division, not as a legal entity column — QueryMovementsAsync returns AnalyticSetMetaId. ListEsvAsync runs ExpandAsync, then Division.LegalEntity.

Movement date is the SocialInsuranceAccrual document date. Since HR 1.9.2 it is copied from PayrollAccrual.DocumentDate. Without this, March payroll wrote unified social contribution (ЄСВ) "today", and the quarterly table for March did not see it.

Therefore TaxReturn holds UaQuarterlyWorkpaperZr + UA-QPR export, not a State Tax Service form. The file itself says it is not a State Tax Service schema. Test: UkraineQuarterlyFilingTest.

State Tax Service forms J0500111 / 4DF / D1 / D5 / D6#

Identifiers are from the State Tax Service register as of 2026-08-01; fields are from the M.E.Doc XML description for J0500111 / J0510411 / J0510111 / J0510511 / J0510611 (Ministry of Finance order 2026-05-07 No. 243). These are not invented 101 values: salary from PayrollAccrual is sign 101; 102 comes from the employee card.

Employee.DpsIncomeSign is supplied by the Employee.LocalizationUkraine extension: string length 3, optional. Empty takes LocalizationUkraineSettings.PayrollIncomeSign, then 101. Employee value 102 has priority: accrued 10,000 goes to report line 1.2, not salary line 1.1. A field read error aborts export; it cannot be treated as empty and silently substituted with 101. The regression is UkraineDpsPayrollFilingTest.FourDfIncomeSignFromEmployee.

D1 T1RXXXXG14 is calendar days of employment relationship in the month, not month length: HireDate / FireDate. T1RXXXXG5 is Ukrainian citizen from Employee.Nationality (ISO UA or a name containing Україн / Ukraine; empty → 1). T1RXXXXG6S is sex Ч/Ж from DpsSex; empty or Latin letters → empty cell, we do not infer it from patronymic. T1RXXXXG8 is insured-person code from appendix 1 to Procedure No. 4: sign 102 → 26, otherwise 1. D5 category (1/2/3) is not copied here: a military person in D5 is 2, while D1 uses another classifier. T1RXXXXG111S / G112S / G113S are DpsLastName / DpsFirstName / DpsPatronymic; empty last name → Employee.Name, and we do not split the first name from a full line. T1RXXXXG12 is days of TimeOff.Kind = Sick in the same window (overlapping sick leaves do not double a day; Vacation/Absence do not count). T1RXXXXG13 is days of TimeOff.DpsUnpaidLeave (not Kind=Absence). T1RXXXXG15 is days of TimeOff.DpsPersonCategory = 5 (maternity); categories 4/6 (care leave) do not go here. T1RXXXXG21 is main place: 1, or 0 if DpsInternalPartTime. T1RXXXXG22 is part-time day: DpsPartTimeHours, not a copy of internal part-time. T1RXXXXG24 is new workplace from DpsNewWorkplace; HireDate in the month does not set 1 (hiring ≠ created workplace). Accrual types 2/3 are not written: PayrollAccrual has no separate amount, and substituting all salary as sick leave is not allowed. G18 (minimum wage) is not written: there is no separate line of type 13. 4DF R00G02I is the number of rows with sign 102.

J0500111 calculation section I: R01011G3 is the 4DF sum with sign 101, R01012G3 is sign 102, R0101G3 = 1.1 + 1.2. Lines 1.3–1.5 (sick leave / maternity by amount) are not written: PayrollAccrualLines has no breakdown, and zeros with non-zero D1 days G12/G15 would lie. R0104 officially means additional accrual of errors, not "recalculated unified social contribution"; the cell is not filled. R0107 = line 3 (4 and 6 empty). SocialInsurancePayment remains a CSV comment; it does not zero R0107.

Rows 2.1 / 2.2 / 2.3 are split by the contribution that was already posted, not by a "disability" flag. EmployerEsv of a D1 line is compared with Capped × rate, rounded as in SocialInsuranceService (2 decimals, away from zero). A match with 8.41% → R01022G3 / R01032G3. A match with 5.3% → R01023G3 / R01033G3. Any other rate, including 22%, remains in R01021G3 / R01031G3. There is no separate field on the card: one rate, HRSettings.SocialInsuranceEmployerRate, applies to locals; another, SocialInsuranceForeignEmployerRate, applies to foreigners; the calculation reads the posted fact. Zero in 2.2 means "there was no such base", not "we invented the rate".

D2 (J0510211) and D3 (J0510311) are not exported: under Procedure No. 4 they are filled by social protection authorities / the Pension Fund and by employers for military service, not by an ordinary employer. D1 must not be copied into D2. The wrapper's open list (G18, types 2/3, lines 1.3–1.5, cabinet / No. 499 / qualified electronic signature (КЕП), 5.3% test) is in localization, section "Return to the wrapper".

D5 reads existing HR data, without separate "hiring order" documents:

  • hiring — Employee.HireDate in the month;
  • dismissal — extension FireDate / FireReason (not IsActive: the flag has no date);
  • transfer — DpsTransferDate (HR has no position history);
  • internal part-time — DpsInternalPartTime → G11;
  • occupational code — Position.DkppCode → G13S, position name remains in G15S;
  • category 2 (military) — DpsLaborCategory, not inferred from citizenship;
  • maternity / care leave — TimeOff.DpsPersonCategory 4/5/6. Vacation and Sick do not by themselves go to D5: annual leave ≠ unpaid leave, sick leave ≠ maternity leave.

D6 is the DpsTenureGround string on the employee (8 characters from appendix 3). Empty means there is no appendix. Do not invent Lists No. 1/2.

What breaks if:

  • You copy accrued into paid without a payment — the cabinet accepts "paid", but the employee has no money. Column T1RXXXXG03 reads PaidBase, which is written only by PayrollPayment Paid.
  • You send DeclarXml to the cabinet by POST without container No. 499 and qualified electronic signature (КЕП) — reception refuses it. We build unsigned DECLAR (order No. 729) next to CSV: import into M.E.Doc / file. C_REG / C_RAJ come from the legal entity. D2/D3 are not written into LINKED_DOCS.
  • You do not fill the taxpayer number (Employee.TaxCardNumber) — column 2 is empty, and the State Tax Service rejects the row. The field is in the Ukraine package, not in HR.
  • You mark TimeOff.Kind = Vacation as D5 without DpsPersonCategory — annual leave enters the file as maternity leave. Therefore there is no Kind → category mapping.
  • You count Kind = Absence as G13 days — it is absence, not unpaid leave. The DpsUnpaidLeave flag is needed.
  • You copy DpsInternalPartTime to G22 — internal part-time and part-time hours are different columns.
  • You treat IsActive = false as dismissal without FireDate — the event date must fall into the reporting month, and the flag carries no date.
  • You set category 2 for every non-UA employee — military status is not citizenship.
  • You put the position name into G13S — it expects DK 003 code; the name remains in G15S.
  • You put D5 category (3) into D1 G8 — civil contract in appendix 1 to Procedure No. 4 is 26, not 3. Military D5=2 is not a D1 code either.
  • You split Employee.Name by spaces into G111–G113 — a double last name becomes a first name.
  • You set G5=1 for everyone with a taxpayer number — a foreigner with a tax number enters the register as a UA citizen. Read Nationality.
  • You invent G6=Ч from patronymic ending in -ovych — the cabinet accepts sex not set in HR. Empty stays empty; Latin M/F is not mapped.
  • You fold all 4DF into R01011 — civil-contract income (102) becomes salary.
  • You put paid unified social contribution into R0104 — this is a cell for additional accrual of errors; the cabinet reads a correction that did not happen. Payment is not a section I row.
  • You zero R0107 after SocialInsurancePayment — officially line 7 = 3 + 4 − 6, not accrued − paid.

UaTaxFilingExport.DeclarXml is unsigned DECLAR (Ministry of Revenue order No. 729, encoding="windows-1251" in the declaration, values in nvarchar Unicode). Roots DECLAR / DECLARHEAD / DECLARBODY, C_DOC/C_DOC_SUB/ C_DOC_VER from the form identifier (J0500111 → J05/001/11). LINKED_DOCS only for existing appendices (101/104, 105/106 if rows exist). Empty G6S/G112S/G113S/G18 and D5/D6 with no events are not written. This is not container No. 499 and not a qualified electronic signature (КЕП).

Test: UkraineDpsPayrollFilingTest (including DpsDeclarXmlForCabinetImport).

VAT declaration (ПДВ) J0200126#

Identifier — from the State Tax Service register as of 2026-08-01. Lines 1.1 / 1.2 / 5 / 10.1 / 10.2 are from the form in Ministry of Finance order 2024-08-09 No. 400, not invented R001G3. The vat-UA delivery seeds TaxReportMapping for these rows. 9 and 17 are formulas from the same order, not "sum of all columns". 18 and 19 are the difference. Line 20.2 is the UaVatRefund document, amount not above line 19. Without an application the row is empty: copying 19 to 20.2 is not allowed. UA-ZX → line 2.1 (export), UA-Z → line 3 (domestic zero). One code for both rows would mix export with domestic zero.

What breaks if:

  • You put UA-S with no direction — purchases enter line 1.1. Therefore the delivery must include direction.
  • You copy line 19 to 20.2 — the cabinet accepts a refund application that did not exist.
  • You name an XML file with R001G3 and no J0200126 schema — reception refuses it.

Test: UkraineVatDeclarationFilingTest.

Identifier — from the same State Tax Service register. The form is for a legal entity in group 3: Ministry of Finance order 2015-06-19 No. 578 as amended by 2025-01-31 No. 57. The vat-UA delivery seeds UA-EP3 → line 1.3 and UA-EP5 → 1.4 (OUTPUT). There is a separate command UaExportSingleTax, so an empty VatReturnType remains J0200126. Line 9 is the ledger from January 1 to the end of the previous quarter (PreviousCumulativeEnd). Appendix J0135709 (minimum tax liability, МПЗ) uses the UaLandPlot dictionary and the rate from LocalizationUkraineSettings.MpzRate (zero = 5%). Under the CSV comment with the total there is one row per plot; row sum = Round(Σvaluation × rate × months / 12), and the last plot takes the rounding penny. UA-EP6 / UA-EP10 → line 2. UA-EPB3/UA-EPB5 → line 3 (non-cash), UA-EPF3/UA-EPF5 → line 4 (prohibited activities) — the same 6% / 10%, but different codes; otherwise barter mixes with exceeding the limit. UA-EP15 is not mapped on this form — it is line 07 of the sole-proprietor form.

What breaks if:

  • You put UA-EP15 into line 2 — the cabinet accepts 15% as a doubled legal-entity rate (6% / 10% under ПКУ 293.5). 15% is F0103309 line 07.
  • You put barter on UA-EP6 — it enters line 2 (limit exceeded), not line 3.
  • You build the March declaration without January 1 — line 1 is monthly, not cumulative, and line 10 is no longer "payable for the quarter".
  • You name the cabinet XML without C_REG/C_STI — reception refuses it.
  • You stamp ReturnBox on a declaration when one code has two ReturnType values — the kernel leaves the cell empty. Export reads TaxReportMapping by form type, not by stamp.

Test: UkraineSingleTaxDeclarationFilingTest.

Sole-proprietor single-tax declaration F0103309#

Same order No. 578 / No. 57, identifier from the register. The vat-UA delivery seeds UA-EP3 → 05, UA-EP5 → 06, UA-EP15 → 07. Separate command UaExportFopSingleTax. Line 23 = 1% of (05+06+07), not code UA-VZ (salary). F0133109 is UaFopEsv movements for the period, folded by UaFopEsvAccrual document (date, number, amount). Row sum = comment. Zero after a reversal in the same period writes no row. F0133209 is the same land-plot rows as J0135709.

What breaks if:

  • You take UA-VZ into line 23 — the sole-proprietor declaration gets military levy from employees' salary, not 1% of single-tax income.
  • You put 15% into the legal-entity form — see above.
  • You fill appendix 1 quarterly — it is submitted once a year, and we do not have a "self unified social contribution" base.

Test: UkraineFopSingleTaxDeclarationFilingTest.

What breaks if#

  • You read column Employee on TR_SocialInsurance — it does not exist, only an analytic set. The file is empty while contributions exist.
  • You filter unified social contribution (ЄСВ) by LegalEntity in SQL — there is no column. Without expanding division, another legal entity mixes in or the result filters to zero.
  • You leave CreateAccrualAsync without payroll date — MovementDate becomes "now", and the declaration period stops matching the accrual.

Same topic for other readers: User

Where next#

Was this page helpful?