📑 Ukrainian VAT (ПДВ) — for developers
Updated
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
UpdateAsyncof the regime — a sole proprietor (ФОП) moved by the job remains with or withoutUA-Eaccording to the previous regime, not the current one. - You hardcode
UA-Ein 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
ContributoryBasefrom 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.UtcNowinstead 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.HireDatein the month; - dismissal — extension
FireDate/FireReason(notIsActive: 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.DpsPersonCategory4/5/6.VacationandSickdo 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
T1RXXXXG03readsPaidBase, which is written only byPayrollPaymentPaid. - You send
DeclarXmlto the cabinet by POST without container No. 499 and qualified electronic signature (КЕП) — reception refuses it. We build unsignedDECLAR(order No. 729) next to CSV: import into M.E.Doc / file.C_REG/C_RAJcome from the legal entity. D2/D3 are not written intoLINKED_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 = Vacationas D5 withoutDpsPersonCategory— annual leave enters the file as maternity leave. Therefore there is no Kind → category mapping. - You count
Kind = Absenceas G13 days — it is absence, not unpaid leave. TheDpsUnpaidLeaveflag is needed. - You copy
DpsInternalPartTimeto G22 — internal part-time and part-time hours are different columns. - You treat
IsActive = falseas dismissal withoutFireDate— 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, not3. Military D5=2is not a D1 code either. - You split
Employee.Nameby spaces into G111–G113 — a double last name becomes a first name. - You set G5=
1for everyone with a taxpayer number — a foreigner with a tax number enters the register as a UA citizen. ReadNationality. - 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
R0107afterSocialInsurancePayment— 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-Swith 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
R001G3and no J0200126 schema — reception refuses it.
Test: UkraineVatDeclarationFilingTest.
Legal-entity single-tax declaration J0103509#
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-EP15into line 2 — the cabinet accepts 15% as a doubled legal-entity rate (6% / 10% under ПКУ 293.5). 15% isF0103309line 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
TaxReportMappingby 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-VZinto 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
EmployeeonTR_SocialInsurance— it does not exist, only an analytic set. The file is empty while contributions exist. - You filter unified social contribution (ЄСВ) by
LegalEntityin SQL — there is no column. Without expanding division, another legal entity mixes in or the result filters to zero. - You leave
CreateAccrualAsyncwithout payroll date —MovementDatebecomes "now", and the declaration period stops matching the accrual.
Same topic for other readers: User