<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Odoo on Lessons learned</title><link>https://eghuro.cz/tags/odoo/</link><description>Recent content in Odoo on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Sun, 13 Sep 2026 14:09:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/odoo/index.xml" rel="self" type="application/rss+xml"/><item><title>A read with legal effect: listing ISDS messages counts as delivery</title><link>https://eghuro.cz/posts/isds-listing-messages-is-legal-delivery/</link><pubDate>Sun, 13 Sep 2026 14:09:00 +0200</pubDate><guid>https://eghuro.cz/posts/isds-listing-messages-is-legal-delivery/</guid><description>&lt;p&gt;Polling an inbox from a cron job looks harmless, since reading has no side effects. In the Czech Data Box system (ISDS, &lt;em&gt;datové schránky&lt;/em&gt;) it does: the moment an authorized person logs in to the box is the moment every message waiting in it is legally delivered (&lt;em&gt;doručení&lt;/em&gt;). An application counts as that person when it logs in and fetches the list of received messages. A 3 a.m. retrieval of a tax office&amp;rsquo;s request starts the deadline to answer it, whether anyone reads the message or not.&lt;/p&gt;&#10;&lt;p&gt;If your code talks to ISDS, every scheduled retrieval and every copy of your database that still holds the box&amp;rsquo;s password is a legal act.&lt;/p&gt;&#10;&#10;&lt;h2 id="setup"&gt;Setup &lt;/h2&gt;&#10;&lt;p&gt;An Odoo 18 addon files incoming Data Box messages into the company&amp;rsquo;s document store (the OCA &lt;code&gt;dms&lt;/code&gt; addon). It talks to ISDS over SOAP with zeep (&lt;a&#10; href="https://eghuro.cz/posts/zeep-repeated-sequence-value-1"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;the parsing bug from that client is here&lt;/a&gt;&#10;). A retrieval calls &lt;code&gt;GetListOfReceivedMessages&lt;/code&gt; for the last 100 days and downloads each new message with its signed copy.&lt;/p&gt;&#10;&lt;p&gt;A possible next feature was a cron job, so that the documents appear without anyone pressing a button.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-a-read-starts-a-deadline"&gt;Why a read starts a deadline &lt;/h2&gt;&#10;&lt;p&gt;Act No. 300/2008 Sb. delivers a message in one of two ways. Under § 17(3), it is delivered when a person whose permissions cover it logs in to the box. Under § 17(4), if nobody logs in within 10 days of the message&amp;rsquo;s arrival, it counts as delivered on the last day of that period, which Czech lawyers call delivery by fiction.&lt;/p&gt;&#10;&lt;p&gt;People rely on fiction as a buffer. A box that nobody opens for a week gives you up to 10 extra days before a deadline starts running. A nightly cron throws that buffer away. Every message is delivered the night after it arrives, and its deadline starts then, while the documents sit unread in a folder.&lt;/p&gt;&#10;&lt;p&gt;Not every call has this effect. &lt;code&gt;GetOwnerInfoFromLogin&lt;/code&gt; checks the credentials and returns the box ID and owner without causing delivery. The addon uses it to validate a login before it creates anything.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-design"&gt;The design &lt;/h2&gt;&#10;&lt;p&gt;Once the domain model had the rule written down (&amp;ldquo;Every Retrieval causes Delivery of the Messages waiting in the box&amp;rdquo;), the decisions followed from it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Each connection has a Retrieval Mode, On demand or Scheduled, and On demand is the default.&lt;/li&gt;&#10;&lt;li&gt;The &amp;ldquo;Check data box&amp;rdquo; button asks for confirmation: &amp;ldquo;Checking the data box counts as Delivery (doručení) of every Message waiting in it, which starts legal deadlines. Continue?&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Choosing Scheduled shows a warning, and the form keeps showing it for as long as the connection stays Scheduled. The daily cron job skips On demand connections.&lt;/li&gt;&#10;&lt;li&gt;The one-step setup wizard says that its first retrieval counts as delivery.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="copies-of-the-production-database"&gt;Copies of the production database &lt;/h2&gt;&#10;&lt;p&gt;The usual way to get a realistic test environment for Odoo is to restore a production backup. That copy holds the ISDS username and password, and possibly a scheduled connection. A staging server that runs the cron, or a developer who presses &amp;ldquo;Check data box&amp;rdquo; to see what it does, delivers the production company&amp;rsquo;s official mail.&lt;/p&gt;&#10;&lt;p&gt;Odoo has a hook for this. When you run &lt;code&gt;odoo-bin neutralize&lt;/code&gt;, or restore a database with &lt;strong&gt;Neutralize&lt;/strong&gt; checked in the database manager, it runs each installed module&amp;rsquo;s &lt;code&gt;data/neutralize.sql&lt;/code&gt;. Core modules use it to switch off outgoing mail and crons. The ISDS addon adds its own:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- a neutralized copy must never cause Delivery: drop the Credential passwords&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- and stop Scheduled Retrievals&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dms_isds_connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;retrieval_mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;on_demand&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- unfinished Quick Starts hold a password too, and connecting retrieves at once&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dms_isds_quick_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A retrieval on a neutralized copy fails on the missing password before it contacts ISDS. The test checks exactly that. It runs the module&amp;rsquo;s neutralization queries, presses the button, triggers the cron, and asserts that the ISDS listing was never called:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_neutralize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertRaisesRegex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_check_data_box&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CRON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method_direct_trigger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assert_not_called&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The development setup restores production data and neutralizes it on every refresh, so nobody has to remember.&lt;/p&gt;&#10;&lt;p&gt;Neutralization changes only the copy. The original backup still holds the password in plaintext, so restrict access to backups and encrypt them.&lt;/p&gt;&#10;&#10;&lt;h2 id="other-reads-with-side-effects"&gt;Other reads with side effects &lt;/h2&gt;&#10;&lt;p&gt;ISDS is an extreme case because the side effect is legal. Plenty of other APIs change state when you read:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;IMAP &lt;code&gt;FETCH BODY[]&lt;/code&gt; sets the &lt;code&gt;\Seen&lt;/code&gt; flag; &lt;code&gt;BODY.PEEK[]&lt;/code&gt; doesn&amp;rsquo;t.&lt;/li&gt;&#10;&lt;li&gt;Receiving from a queue hides or consumes the message.&lt;/li&gt;&#10;&lt;li&gt;Opening a message can send a read receipt the sender treats as proof.&lt;/li&gt;&#10;&lt;li&gt;One-time codes and signed links stop working after the first use.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each of these needs the same treatment as outgoing mail: an explicit default, a warning where a person triggers it, and a neutralization step for every copy of the data.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Before you schedule a &amp;ldquo;read-only&amp;rdquo; integration, find out what reading does on the other side. In ISDS, it is legal delivery, so the integration retrieves on demand by default, and a restored database can&amp;rsquo;t log in.&lt;/p&gt;&#10;&lt;p&gt;List the integrations in your own staging setup that still hold production credentials, and check whether any of their reads change something for someone else.&lt;/p&gt;&#10;</description></item><item><title>Uninstalling one Odoo module can remove its dependents and their data</title><link>https://eghuro.cz/posts/uninstalling-one-module-can-cascade/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://eghuro.cz/posts/uninstalling-one-module-can-cascade/</guid><description>&lt;p&gt;An Odoo module replacement passed a rehearsal&amp;rsquo;s record-count and accounting-total checks, yet the uninstall also removed a dependent addon that owned important business data. The application still loaded. The acceptance checks had missed the field that mattered.&lt;/p&gt;&#10;&lt;p&gt;Before replacing a module, inspect its reverse dependencies and data ownership. Rehearse the corrected transition on a restored copy, then compare the affected values by record identity before approving the live change.&lt;/p&gt;&#10;&#10;&lt;h2 id="uninstall-computes-a-reverse-dependency-set"&gt;Uninstall computes a reverse-dependency set &lt;/h2&gt;&#10;&lt;p&gt;In &lt;a&#10; href="https://github.com/odoo/odoo/blob/18.0/odoo/addons/base/models/ir_module.py"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Odoo 18&amp;rsquo;s module implementation&lt;/a&gt;&#10;, &lt;code&gt;downstream_dependencies()&lt;/code&gt; recursively finds modules that depend on the selected modules. &lt;code&gt;button_uninstall()&lt;/code&gt; marks both the selection and those dependents as &lt;code&gt;to remove&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The same source describes &lt;code&gt;module_uninstall()&lt;/code&gt; as removing database structures created by the module, including tables and columns. Actual deletion depends on the affected records and metadata; do not assume every business record disappears, or that surviving rows retain every field.&lt;/p&gt;&#10;&lt;p&gt;Generalize the replacement to a full addon, a smaller replacement, and an extension that depends on the full addon. Installing the smaller addon does not automatically redirect the extension&amp;rsquo;s dependency. Uninstalling the full addon still includes that extension in the removal set.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-first-rehearsal-had-the-wrong-acceptance-criteria"&gt;The first rehearsal had the wrong acceptance criteria &lt;/h2&gt;&#10;&lt;p&gt;The historical transition checked surviving records, aggregate totals, and registry health. Those checks passed while the dependent addon was also uninstalled. The user caught the missing preservation requirement and required a restore.&lt;/p&gt;&#10;&lt;p&gt;The subsequent investigation found a second problem: changing the extension&amp;rsquo;s manifest alone would be insufficient. The smaller replacement did not yet implement a field the extension required. That field&amp;rsquo;s behavior needed to be ported before changing the dependency.&lt;/p&gt;&#10;&lt;p&gt;The retained record includes the corrective restore and a later clone&amp;rsquo;s uninstall/registry-reload output. It ends with the corrected rehearsal and final verification still open. It does not prove a successful final transition or a completed before/after digest comparison.&lt;/p&gt;&#10;&#10;&lt;h2 id="rehearse-the-field-handoff-explicitly"&gt;Rehearse the field handoff explicitly &lt;/h2&gt;&#10;&lt;p&gt;A safer transition procedure is:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Quiesce writes and capture a consistent database and filestore backup. Verify checksums and prove that the pair restores.&lt;/li&gt;&#10;&lt;li&gt;Record installed module states, reverse dependencies, and the fields and records that must survive. Keep the addon source revisions with the backup.&lt;/li&gt;&#10;&lt;li&gt;Implement the required field behavior in the replacement, including compute and reversal behavior where applicable. Version-control the dependent addon too.&lt;/li&gt;&#10;&lt;li&gt;On an isolated restore, install the replacement and upgrade the dependent addon so the database&amp;rsquo;s module metadata reflects the new dependency. Inspect the removal set again before uninstalling the old addon.&lt;/li&gt;&#10;&lt;li&gt;Complete the uninstall, reload the registry, and compare the required values and behavior. Investigate field metadata and ownership during the handoff; identical field names alone do not establish safe transfer.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Disable scheduled jobs and outbound integrations on the clone. A rehearsal should not send mail, contact payment services, or write to production storage.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-identity-and-value-not-only-counts"&gt;Compare identity and value, not only counts &lt;/h2&gt;&#10;&lt;p&gt;For a small affected dataset, compare canonical exports directly. For larger exports, a digest is convenient if the query scope, ordering, and serialization remain identical.&lt;/p&gt;&#10;&lt;p&gt;This synthetic Python check demonstrates why counts and non-null counts are insufficient. It models two records whose field values were exchanged; it is not an Odoo migration test:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;hashlib&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;json&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;,&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;:&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;utf-8&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-20&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-20&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-01-10&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;counts match; identity/value digest detects the change&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;For a real comparison, export the stable record ID alongside each affected field, preserve nulls, and normalize dates and other types explicitly. A missing column must fail the check. Also test compute behavior and important workflows: a value digest cannot prove those work.&lt;/p&gt;&#10;&lt;p&gt;Before your next uninstall, list which dependent addons and field values must remain, and make their survival a rehearsal acceptance criterion.&lt;/p&gt;&#10;</description></item><item><title>ISDOC.PDF: an XML invoice in a PDF, and the VAT date it can't structure</title><link>https://eghuro.cz/posts/isdoc-pdf-line-level-tax-date/</link><pubDate>Sun, 09 Aug 2026 05:06:00 +0200</pubDate><guid>https://eghuro.cz/posts/isdoc-pdf-line-level-tax-date/</guid><description>&lt;p&gt;Czech VAT law lets you put a month of separate supplies on one tax document, as long as each supply states its own date. ISDOC, the national e-invoicing format, has one tax date per document. Move that element onto an invoice line and validation fails:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Element &amp;#39;TaxPointDate&amp;#39;: This element is not expected.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Expected is one of ( Note, VATNote, Item, ... )&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A human reading the PDF sees every date. The accounting software importing the XML sees one.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-isdoc-is"&gt;What ISDOC is &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://isdoc.cz"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;ISDOC&lt;/a&gt;&#10; is an XML invoice format, free to use, and maintained by the Czech Ministry of the Interior as the &amp;ldquo;national standard for electronic invoicing.&amp;rdquo; The current version is 6.0.2 from March 2022. &lt;a&#10; href="https://github.com/isdoc/schema/blob/main/FAQ.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Many accounting programs&lt;/a&gt;&#10; open it directly, so nobody retypes the invoice. A &lt;a&#10; href="https://mf.gov.cz/cs/dane-a-ucetnictvi/elektronicka-fakturace/zakladni-informace"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;2018 Ministry of Finance notice&lt;/a&gt;&#10; also lists ISDOC 5.2 and later among the formats central government bodies must accept.&lt;/p&gt;&#10;&lt;p&gt;A document comes in &lt;a&#10; href="https://isdoc.cz/6.0.2/doc/isdoc.html#reprezentace"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;three representations&lt;/a&gt;&#10;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;.isdoc&lt;/code&gt;: plain XML in UTF-8&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;-isdoc.pdf&lt;/code&gt;: the XML embedded in a PDF/A-3, known as ISDOC.PDF&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;.isdocx&lt;/code&gt;: an archive with a manifest and attachments&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;ISDOC.PDF is the practical choice when you don&amp;rsquo;t know the recipient&amp;rsquo;s software. It opens like any PDF, and software that understands the format extracts the XML. German ZUGFeRD and French Factur-X use the same idea.&lt;/p&gt;&#10;&#10;&lt;h2 id="making-an-isdocpdf"&gt;Making an ISDOC.PDF &lt;/h2&gt;&#10;&lt;p&gt;The spec&amp;rsquo;s requirements for the PDF:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The PDF is PDF/A-3&lt;strong&gt;a&lt;/strong&gt;, and the file name ends in &lt;code&gt;-isdoc.pdf&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The embedded XML is named exactly &lt;code&gt;invoice.isdoc&lt;/code&gt;, with subtype &lt;code&gt;text/xml&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Its &lt;code&gt;/Filespec&lt;/code&gt; is listed both in the catalog&amp;rsquo;s &lt;code&gt;/AF&lt;/code&gt; array and in the &lt;code&gt;/EmbeddedFiles&lt;/code&gt; name tree.&lt;/li&gt;&#10;&lt;li&gt;The &lt;code&gt;/Filespec&lt;/code&gt; has &lt;code&gt;/F&lt;/code&gt;, &lt;code&gt;/UF&lt;/code&gt; and &lt;code&gt;/EF&lt;/code&gt;, and &lt;code&gt;/EF&lt;/code&gt; holds both &lt;code&gt;F&lt;/code&gt; and &lt;code&gt;UF&lt;/code&gt; stream entries.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;/AFRelationship&lt;/code&gt; is &lt;code&gt;/Source&lt;/code&gt; if the PDF was rendered from the XML, or &lt;code&gt;/Alternative&lt;/code&gt; if both came from your database.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Generate the XML first, since LaTeX has to find it at compile time. With &lt;code&gt;pdflatex&lt;/code&gt;, the preamble is short:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-latex" data-lang="latex"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;\usepackage&lt;/span&gt;&lt;span class="na"&gt;[a-3b]&lt;/span&gt;&lt;span class="nb"&gt;{&lt;/span&gt;pdfx&lt;span class="nb"&gt;}&lt;/span&gt; &lt;span class="c"&gt;% needs \jobname.xmpdata next to the .tex&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;\usepackage&lt;/span&gt;&lt;span class="nb"&gt;{&lt;/span&gt;embedfile&lt;span class="nb"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;\embedfile&lt;/span&gt;[&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; filespec=invoice.isdoc,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; mimetype=text/xml,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; afrelationship=Alternative&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;]&lt;span class="nb"&gt;{&lt;/span&gt;invoice.isdoc&lt;span class="nb"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you already have a PDF, the &lt;a&#10; href="https://github.com/isdoc/isdoc.pdf"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;official repository&lt;/a&gt;&#10; has a Java tool that joins a PDF and an ISDOC file and links to a Ghostscript-based script. Its &lt;code&gt;examples/output&lt;/code&gt; folder is the reference to compare against.&lt;/p&gt;&#10;&lt;p&gt;The ministry &lt;a&#10; href="https://github.com/isdoc/schema/blob/main/FAQ.md"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;doesn&amp;rsquo;t run a validator&lt;/a&gt;&#10;, so check the output yourself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;xmllint --noout --schema isdoc-invoice-6.0.2.xsd invoice.isdoc&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfdetach -list invoice-isdoc.pdf &lt;span class="c1"&gt;# 1: invoice.isdoc&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfdetach -save &lt;span class="m"&gt;1&lt;/span&gt; -o extracted.isdoc invoice-isdoc.pdf&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp extracted.isdoc invoice.isdoc&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfinfo -meta invoice-isdoc.pdf &lt;span class="p"&gt;|&lt;/span&gt; grep pdfaid&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pdfinfo invoice-isdoc.pdf &lt;span class="p"&gt;|&lt;/span&gt; grep Tagged&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;These commands don&amp;rsquo;t prove PDF/A conformance. For that, use a validator such as veraPDF. XSD validation also doesn&amp;rsquo;t run ISDOC&amp;rsquo;s Schematron rules.&lt;/p&gt;&#10;&#10;&lt;h3 id="what-the-checks-turned-up"&gt;What the checks turned up &lt;/h3&gt;&#10;&lt;p&gt;Our generator&amp;rsquo;s XML validated, and we extracted its attachment byte for byte. The PDF still differs from the spec in two ways, as a comparison with an official sample showed:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Official sample&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;&lt;code&gt;pdfx&lt;/code&gt; + &lt;code&gt;embedfile&lt;/code&gt;&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;pdfaid&lt;/code&gt; conformance&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;B&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Tagged&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;yes&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;no&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;/EF&lt;/code&gt; keys&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;F&lt;/code&gt;, &lt;code&gt;UF&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;F&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;A tolerant reader won&amp;rsquo;t care, and &lt;code&gt;pdfdetach&lt;/code&gt; didn&amp;rsquo;t. A strict one could. PDF/A-3a needs a fully tagged document, which this &lt;code&gt;pdflatex&lt;/code&gt; setup doesn&amp;rsquo;t produce.&lt;/p&gt;&#10;&lt;p&gt;Three other traps:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Optional validation can skip silently.&lt;/strong&gt; Our &lt;code&gt;--xsd&lt;/code&gt; flag printed &amp;ldquo;validation skipped&amp;rdquo; and carried on, because &lt;code&gt;lxml&lt;/code&gt; was missing. In CI, make a skipped check fail.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;TaxPointDate&lt;/code&gt; is not the issue date.&lt;/strong&gt; A summary document can go out up to 15 days after the end of the month, so the two differ by design.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Signing happens before embedding.&lt;/strong&gt; The ISDOC signature is an enveloped XMLDSig inside the XML. The transform removes only the &lt;code&gt;Signature&lt;/code&gt; element, so reformatting the file afterward breaks the digest. A signature on the PDF is a separate step and should be an incremental update, which leaves the embedded bytes alone.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="what-the-law-requires-and-the-standard-lacks"&gt;What the law requires and the standard lacks &lt;/h2&gt;&#10;&lt;p&gt;The summary tax document (&lt;em&gt;souhrnný daňový doklad&lt;/em&gt;, § 31b of Act No. 235/2004 Sb.) covers several separate supplies to one customer within a calendar month. Under § 31b(4), it must state separately for each supply its scope, &lt;em&gt;date&lt;/em&gt;, unit price, tax base, rate and tax, and for each received payment its &lt;em&gt;date of receipt&lt;/em&gt;. Data common to all of them can appear once (§ 31b(3)), so a header date is enough only if every supply happened on the same day.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;VAT Act&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;ISDOC 6.0.2&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Gap&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 31b(4)(a)(2): date of each supply&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;TaxPointDate&lt;/code&gt; on &lt;code&gt;Invoice&lt;/code&gt; only&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No structured field per line&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 31b(4)(b)(2): date of each payment&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;TaxedDeposit&lt;/code&gt; has no date&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No structured field per payment&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 31b: a separate kind of document&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;DocumentType&lt;/code&gt; 1–7, none for it, and the spec&amp;rsquo;s definition of &amp;ldquo;tax document&amp;rdquo; doesn&amp;rsquo;t list it&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Receiver can&amp;rsquo;t tell from the standard fields&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 29(1)(h): date of supply on a tax document&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;TaxPointDate&lt;/code&gt; is optional in the XSD, and only the spec&amp;rsquo;s prose says to fill it&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;XSD accepts a document without it&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 26(3): customer consents to electronic form&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;ElectronicPossibilityAgreementReference&lt;/code&gt; is mandatory, but empty validates&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Presence is checked, consent is not&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;§ 34: origin and integrity&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Optional XML signature&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;None: a recognized signature is one route among several&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;I tested the per-line and document-type rows by editing a valid file: &lt;code&gt;TaxPointDate&lt;/code&gt; on a line, &lt;code&gt;TaxPointDate&lt;/code&gt; on an item, and &lt;code&gt;DocumentType&lt;/code&gt; 8 each failed the XSD. The payment row comes from reading &lt;code&gt;TaxedDepositType&lt;/code&gt;. This is my reading of the statute and the schema, not a ruling or tax advice. Nor does it make an ISDOC.PDF unlawful. If the visible document prints every date, the data is on the document. The gap is that the machine-readable half can&amp;rsquo;t carry it in a form a recipient&amp;rsquo;s software will understand.&lt;/p&gt;&#10;&lt;p&gt;The spec says its rules should, where possible, be independent of legislation. That is a fair position for validation rules. It doesn&amp;rsquo;t help when the structure has nowhere to put a value the law requires.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-implementations-have-the-same-shape"&gt;The implementations have the same shape &lt;/h2&gt;&#10;&lt;p&gt;Header-only dates aren&amp;rsquo;t unique to ISDOC. In Odoo 18, &lt;code&gt;account.move.line.date&lt;/code&gt; is a read-only related field that mirrors the journal entry&amp;rsquo;s date, and the Czech localization adds a single &lt;code&gt;taxable_supply_date&lt;/code&gt; to the entry, defaulting to today. Modeling this correctly took a dedicated per-line field and a rule that derives the header from it. &lt;a&#10; href="https://github.com/isdoc/schema/issues/7"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;The upstream issue&lt;/a&gt;&#10; also notes that many commercial accounting systems lack per-line dates.&lt;/p&gt;&#10;&lt;p&gt;Our generator has its own limits: one VAT rate per invoice, CZK only, no deposits, and no reverse charge.&lt;/p&gt;&#10;&lt;p&gt;EN 16931, the European e-invoicing norm, offers a partial precedent. It has an &lt;a&#10; href="https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/cac-InvoiceLine/cac-InvoicePeriod"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;invoice line period&lt;/a&gt;&#10; (BG-26) with start and end dates. A line period is not a tax date, but it shows that per-line dates fit in an invoice model.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-to-do-today"&gt;What to do today &lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Print each supply&amp;rsquo;s date in the visible PDF. That is the part a person, or a tax auditor, reads.&lt;/li&gt;&#10;&lt;li&gt;Set &lt;code&gt;TaxPointDate&lt;/code&gt; to the last supply date. Section 31b doesn&amp;rsquo;t define a document-level date, but the &lt;a&#10; href="https://adisspr.mfcr.cz/dpr/adis/idpr_pub/dph/kh1/pokyny/pokyny_dphkh1_2024.pdf"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;2024 VAT control statement instructions&lt;/a&gt;&#10; ask for &amp;ldquo;the last (calendar) date&amp;rdquo; of the supplies on a summary document, so this keeps the two consistent.&lt;/li&gt;&#10;&lt;li&gt;Put each date on its own line in a fixed format. Both of these validate:&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Note&amp;gt;&lt;/span&gt;Datum plnění: 2026-07-14&lt;span class="nt"&gt;&amp;lt;/Note&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Item&amp;gt;&amp;lt;Description&amp;gt;&lt;/span&gt;Work A&lt;span class="nt"&gt;&amp;lt;/Description&amp;gt;&amp;lt;/Item&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;Extensions&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;x:TaxPointDate&lt;/span&gt; &lt;span class="na"&gt;xmlns:x=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;urn:example:line-tax-point&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;2026-07-14&lt;span class="nt"&gt;&amp;lt;/x:TaxPointDate&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/Extensions&amp;gt;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ol start="4"&gt;&#10;&lt;li&gt;Agree on the format with any recipient who needs to import the dates. A standard importer won&amp;rsquo;t read your note or your namespace.&lt;/li&gt;&#10;&lt;li&gt;If a recipient needs structured dates and can&amp;rsquo;t parse those, issue one ordinary invoice per date. It costs you the summary document, but the data is exact.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The proper fix looks small: in a new schema version, an optional &lt;code&gt;TaxPointDate&lt;/code&gt; on &lt;code&gt;InvoiceLine&lt;/code&gt; and a date on payments, with the header kept as it is. &lt;a&#10; href="https://github.com/isdoc/schema/issues/7"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Issue #7&lt;/a&gt;&#10; reports the missing per-line date and is open.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;XSD-valid doesn&amp;rsquo;t mean legally complete, and legally complete doesn&amp;rsquo;t mean importable. Before you adopt an exchange format for regulated data, list what the law requires per record and find the element that holds each field. Any requirement that ends up in a free-text note is one your recipient&amp;rsquo;s software can&amp;rsquo;t see.&lt;/p&gt;&#10;</description></item></channel></rss>