<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Peter Hallander</title>
    <description>The latest articles on DEV Community by Peter Hallander (@stackedboost).</description>
    <link>https://dev.to/stackedboost</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3843719%2F0ea5966b-9e47-4633-b38c-5ca07e7f14b5.JPG</url>
      <title>DEV Community: Peter Hallander</title>
      <link>https://dev.to/stackedboost</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stackedboost"/>
    <language>en</language>
    <item>
      <title>Validate Poland's e-invoices offline against the official XSD, then make your validator fail on purpose</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:02:34 +0000</pubDate>
      <link>https://dev.to/stackedboost/validate-polands-e-invoices-offline-against-the-official-xsd-then-make-your-validator-fail-on-53pe</link>
      <guid>https://dev.to/stackedboost/validate-polands-e-invoices-offline-against-the-official-xsd-then-make-your-validator-fail-on-53pe</guid>
      <description>&lt;p&gt;Since 2026, invoices between Polish businesses are XML documents. They go to the national e-invoicing system, KSeF, in a schema called FA(3). The last exemptions end on 1 January 2027. Once KSeF accepts an invoice, you cannot edit it or withdraw it. The only fix is a correcting invoice, filed after the fact.&lt;/p&gt;

&lt;p&gt;So when I changed the XML builder to add three new blocks to every invoice (a third party, a payment section with bank accounts, and a footer), I wanted proof that the documents were still valid before any of them reached the real system.&lt;/p&gt;

&lt;p&gt;There are two ways to get that proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slow way: the government sandbox
&lt;/h2&gt;

&lt;p&gt;KSeF has a demo environment. To use it you log in through the national ID service, generate a token, open a session and send your documents. In my setup the demo token has to be regenerated every day. When something is wrong, you get an error code and a sentence in Polish, and then you fix one thing and send again.&lt;/p&gt;

&lt;p&gt;It is the final word on whether an invoice is accepted. It is also far too slow to run every time you touch the builder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fast way: the XSD
&lt;/h2&gt;

&lt;p&gt;The Ministry of Finance publishes the FA(3) schema as an ordinary XSD file on crd.gov.pl. It is 180 KB, it declares 331 elements, and it imports a shared type library, which imports one more. Python's &lt;code&gt;lxml&lt;/code&gt; compiles all three without any special setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;lxml&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;etree&lt;/span&gt;

&lt;span class="n"&gt;XSD_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://crd.gov.pl/wzor/2025/06/25/13775/schemat.xsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;XSD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tmp/fa3/schemat.xsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;XSD&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlretrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;XSD_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;XSD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;etree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;XMLSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;etree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;XSD&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tmp/fa3/*.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;etree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VALID  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INVALID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;     line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four documents, including fetching the two imported files, take about 1.3 seconds. No token, no login, no network round trip per invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate what your code produces, not what you typed
&lt;/h2&gt;

&lt;p&gt;The obvious move is to write a sample XML by hand and validate it. That proves you can write a valid invoice. It says nothing about your builder.&lt;/p&gt;

&lt;p&gt;So the samples come from the real serializer. A small script calls the same function the application uses and writes the output to disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a-baseline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;invoiceNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TEST/A/1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b-wdt-factor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 WDT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;c-own-account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bankAccounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ownAccount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;d-footer-only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Kapitał zakładowy: 50 000 PLN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`tmp/fa3/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.xml`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildFa3Xml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each sample covers one new path: nothing configured, everything at once (a foreign buyer in euros with two factor accounts and a footer), own bank account only, footer only. The baseline matters as much as the others. A change that adds optional blocks must not change the document when they are empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the order is the whole problem
&lt;/h2&gt;

&lt;p&gt;FA(3) is built from &lt;code&gt;xsd:sequence&lt;/code&gt;, so the order of elements is part of the contract. The root looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Faktura
  Naglowek
  Podmiot1              seller
  Podmiot2              buyer
  Podmiot3        0-100 third parties (factor, recipient, ...)
  PodmiotUpowazniony 0-1
  Fa                    the invoice itself; Platnosc sits inside it, after the rows
  Stopka          0-1   footer
  Zalacznik       0-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My three new blocks land in three different places: one between the buyer and the invoice body, one deep inside the body after the line items, one after the body. An XML builder that emits them in a slightly different place produces a document that looks perfectly reasonable and is invalid.&lt;/p&gt;

&lt;p&gt;The schema also knows things that no summary in the documentation mentions. A factor may have at most 20 bank accounts. An account number is any string of 10 to 34 characters. The third party's role is a number from a closed list, where 1 means "factor". When I read these rules in the XSD instead of in a PDF guide, I found them faster and trusted them more.&lt;/p&gt;

&lt;h2&gt;
  
  
  All four passed. That was the moment to worry.
&lt;/h2&gt;

&lt;p&gt;Every sample was valid on the first run.&lt;/p&gt;

&lt;p&gt;A validator that has never said no has not been tested. Maybe the schema failed to load its imports and validated nothing. Maybe the glob matched an empty folder. Maybe I validated yesterday's files. A green result from any of those looks exactly like a real one.&lt;/p&gt;

&lt;p&gt;So I broke a document on purpose. I took the most complex sample and moved the &lt;code&gt;Podmiot3&lt;/code&gt; block from before &lt;code&gt;Fa&lt;/code&gt; to after it. Same content, wrong place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INVALID z-control.xml
     line 80 - Element '{http://crd.gov.pl/wzor/2025/06/25/13775/}Podmiot3':
     This element is not expected. Expected is one of (
     {http://crd.gov.pl/wzor/2025/06/25/13775/}Stopka,
     {http://crd.gov.pl/wzor/2025/06/25/13775/}Zalacznik ).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the four green results mean something. The message is also more precise than a rejection code: it names the line, the element, and the elements that would be allowed there.&lt;/p&gt;

&lt;p&gt;If you keep one habit from this post, keep that one. Every validator, linter or test gate you add needs one input that must fail, run at least once, on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the schema cannot tell you
&lt;/h2&gt;

&lt;p&gt;The XSD checks structure: order, types, lengths, allowed values. It does not check meaning.&lt;/p&gt;

&lt;p&gt;The NIP, the Polish tax number, is a good example. Its type in the shared library is a regular expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1-9]((\d[1-9])|([1-9]\d))\d{7}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;1234567890&lt;/code&gt; matches it. Its checksum is wrong, so it cannot be anyone's tax number, and the schema will not notice. The same goes for arithmetic: the schema cannot see whether the line totals add up to the invoice total.&lt;/p&gt;

&lt;p&gt;KSeF checks meaning on submission, and rejects with its own errors, for example code 450, "Błąd weryfikacji semantyki dokumentu faktury" (semantic verification failed). So the offline check is the fast first gate, not the last one. One real submission through the sandbox is still the final check. The difference is that it becomes one submission to confirm, not twenty to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, in short
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Download the XSD once and keep it next to your tests. It pulls two more files from crd.gov.pl on compile; cache them too if your CI has no network.&lt;/li&gt;
&lt;li&gt;Generate samples with your real builder, one per code path, plus a baseline.&lt;/li&gt;
&lt;li&gt;Keep one sample that must fail, and look at it fail.&lt;/li&gt;
&lt;li&gt;Treat a pass as "structurally valid", never as "accepted".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I build &lt;a href="https://fakturaflow.pl" rel="noopener noreferrer"&gt;FakturaFlow&lt;/a&gt;, which sends invoices to KSeF in bulk. If you need the field-by-field map of FA(3) in Polish, which is the language the people filing these invoices work in, I keep one here: &lt;a href="https://fakturaflow.pl/blog/fa3-schemat-faktury-ksef" rel="noopener noreferrer"&gt;FA(3) schema guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>xml</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>wp_kses deletes tags you forgot to allow, and tells you nothing</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:26:25 +0000</pubDate>
      <link>https://dev.to/stackedboost/wpkses-deletes-tags-you-forgot-to-allow-and-tells-you-nothing-3d77</link>
      <guid>https://dev.to/stackedboost/wpkses-deletes-tags-you-forgot-to-allow-and-tells-you-nothing-3d77</guid>
      <description>&lt;p&gt;I shipped a WordPress plugin last week whose only job is to reproduce a specific, legally prescribed document on a product page. Exact colours, exact layout, nothing editable.&lt;/p&gt;

&lt;p&gt;It went live rendering as unstyled text.&lt;/p&gt;

&lt;p&gt;The CSS file was correct. The markup was correct. &lt;code&gt;php -l&lt;/code&gt; passed on every file, the official Plugin Check tool reported no errors, and reading the diff told me nothing. The bug was &lt;code&gt;wp_kses&lt;/code&gt;, and the reason it took me an hour is that &lt;code&gt;wp_kses&lt;/code&gt; does not fail in a way you can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp_kses( $html, $allowed )&lt;/code&gt; filters HTML against an allow-list. Everyone knows that. What is easy to miss is what it does with a tag that is &lt;strong&gt;not&lt;/strong&gt; on the list.&lt;/p&gt;

&lt;p&gt;It does not remove the element. It removes the &lt;strong&gt;tag&lt;/strong&gt; and keeps the children.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;wp_kses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'&amp;lt;section class="card"&amp;gt;&amp;lt;p&amp;gt;Still here&amp;lt;/p&amp;gt;&amp;lt;/section&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'p'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'class'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// =&amp;gt; '&amp;lt;p&amp;gt;Still here&amp;lt;/p&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; is gone. The paragraph survived. No notice, no warning, no return value to check. Run that in &lt;code&gt;wp shell&lt;/code&gt; and watch it happen.&lt;/p&gt;

&lt;p&gt;That behaviour is correct and deliberate: kses is a sanitiser, and throwing away a user's text because their comment had an unexpected wrapper would be much worse. But when you point kses at &lt;strong&gt;your own&lt;/strong&gt; markup, the failure mode inverts. You do not lose content. You lose structure, and structure is where all your CSS hooks live.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it looked in practice
&lt;/h2&gt;

&lt;p&gt;My notice is built like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"note"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;header&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__head"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__emblem"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;LEGAL GUARANTEE&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        ...
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and echoed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;wp_kses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;markup&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;SBGN_Kses&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;notice&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My allow-list had &lt;code&gt;div&lt;/code&gt;, &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;span&lt;/code&gt;, &lt;code&gt;ul&lt;/code&gt;, &lt;code&gt;li&lt;/code&gt;, &lt;code&gt;h1&lt;/code&gt;–&lt;code&gt;h3&lt;/code&gt;, &lt;code&gt;strong&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;aside&lt;/code&gt;, &lt;code&gt;figure&lt;/code&gt;, &lt;code&gt;figcaption&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt; and &lt;code&gt;a&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;section&lt;/code&gt;. No &lt;code&gt;header&lt;/code&gt;. No &lt;code&gt;ol&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So on every product page the browser received this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__emblem"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;LEGAL GUARANTEE&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"sbgn__body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    ...
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root &lt;code&gt;.sbgn&lt;/code&gt; element carried the border, the background and the CSS custom properties everything else referenced. &lt;code&gt;.sbgn__head&lt;/code&gt; carried the coloured header bar. Both were deleted, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every &lt;code&gt;var(--brand)&lt;/code&gt; resolved to nothing, because the custom properties were declared on &lt;code&gt;.sbgn&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the header bar vanished entirely&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;ol&amp;gt;&lt;/code&gt; was stripped too, so numbered steps quietly became bullets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The page still looked populated. All the text was there, in roughly the right order. It just had none of the design, which for this particular plugin is the entire product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing caught it
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;php -l&lt;/code&gt; passes.&lt;/strong&gt; The PHP is valid. It always was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin Check passes.&lt;/strong&gt; It reads the source, and the source is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CSS is served and parses.&lt;/strong&gt; I fetched the stylesheet URL directly: HTTP 200, right content, braces balanced. I wasted time on that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The class names match.&lt;/strong&gt; Grepping the rendered page for &lt;code&gt;class="sbgn&lt;/code&gt; returned &lt;code&gt;sbgn__body&lt;/code&gt;, &lt;code&gt;sbgn__title&lt;/code&gt;, &lt;code&gt;sbgn-qr&lt;/code&gt; and friends. They were all present, which made it look like the CSS simply was not applying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell, when I finally saw it, was an absence: &lt;code&gt;class="sbgn"&lt;/code&gt; and &lt;code&gt;class="sbgn__head"&lt;/code&gt; were the only two selectors in my stylesheet with no counterpart in the DOM. A missing string is much harder to notice than a wrong one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the check that actually found it&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'class="sbgn[^"]*"'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything my stylesheet defined, minus the two that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the guard
&lt;/h2&gt;

&lt;p&gt;The fix is obvious once you know. List every tag you emit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'div'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'section'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// added&lt;/span&gt;
    &lt;span class="s1"&gt;'header'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// added&lt;/span&gt;
    &lt;span class="s1"&gt;'footer'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// added&lt;/span&gt;
    &lt;span class="s1"&gt;'p'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'ul'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'ol'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// added&lt;/span&gt;
    &lt;span class="s1"&gt;'li'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attributes are silently dropped the same way, which is its own small trap. My notice carried &lt;code&gt;role="note"&lt;/code&gt;; &lt;code&gt;role&lt;/code&gt; was not in the attribute list, so the element survived while its announcement to a screen reader did not. Nothing in the visual output would ever have told me.&lt;/p&gt;

&lt;p&gt;The guard I added is a comment pointing at a one-line command, because the allow-list and the markup live in different files and will drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ohE&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;[a-z][a-z0-9]*"&lt;/span&gt; includes/class-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-notice&lt;/span&gt;.php includes/class-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="nt"&gt;-label&lt;/span&gt;.php &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that, compare against the allow-list, done. If you would rather have it fail loudly in development, &lt;code&gt;wp_kses&lt;/code&gt; gives you no hook for that, but you can diff the two strings yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_kses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$markup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$allowed&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_DEBUG'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;WP_DEBUG&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$markup&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;trigger_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'kses altered plugin markup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;E_USER_WARNING&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crude, but it turns a silent structural edit into something you can see. For your own trusted markup, where the output should be byte-identical, any difference at all is a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;I had two correct artifacts, a correct template and a correct stylesheet, and a broken page. The defect lived in the transformation between them, and every tool I owned inspected the artifacts rather than the result.&lt;/p&gt;

&lt;p&gt;Escaping late is the right rule and I am not arguing with it. But an allow-list is a second source of truth about your own markup, and a second source of truth drifts. Whatever you are building, the check that would have caught this in five seconds is looking at what the browser actually received.&lt;/p&gt;

&lt;p&gt;I only found it because I screenshotted the rendered output on a real install before writing the listing. That is now a release step rather than a nice-to-have.&lt;/p&gt;




&lt;p&gt;The plugin, for the curious, shows the EU's harmonised legal guarantee notice, which becomes mandatory for anyone selling goods to EU consumers on 27 September 2026. It renders in all 24 official EU languages, and as real text rather than the flat artwork everything else in this category ships, which is the only reason a screen reader can read it at all. Free, WooCommerce optional, and I maintain it: &lt;a href="https://stackedboost.com/woocommerce/stackedboost-eu-guarantee-notice/" rel="noopener noreferrer"&gt;EU Legal Guarantee Notice and GARAN Durability Label for WooCommerce&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>toISOString() put 328 invoices in the wrong tax month</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:28:12 +0000</pubDate>
      <link>https://dev.to/stackedboost/toisostring-put-328-invoices-in-the-wrong-tax-month-2fj4</link>
      <guid>https://dev.to/stackedboost/toisostring-put-328-invoices-in-the-wrong-tax-month-2fj4</guid>
      <description>&lt;p&gt;A customer emailed me on 1 September. Her invoices were dated 31 August, and they should have said 1 September.&lt;/p&gt;

&lt;p&gt;She was right. 328 of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line
&lt;/h2&gt;

&lt;p&gt;The invoices arrive from a billing platform by webhook. Each one carries a unix timestamp, and we turn it into a date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;issueDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks like it produces a date. It produces a UTC date.&lt;/p&gt;

&lt;p&gt;Poland runs UTC+2 in summer. The billing platform runs its subscription cycle at 00:00 Europe/Warsaw. So an invoice issued at midnight on 1 September is &lt;code&gt;2026-08-31T22:00:00Z&lt;/code&gt;, and &lt;code&gt;toISOString()&lt;/code&gt; renders it as &lt;strong&gt;2026-08-31&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every invoice in that run was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it hid for a month
&lt;/h2&gt;

&lt;p&gt;The bug only fires between midnight and 02:00 local time. Every invoice issued during the working day converts correctly, because the UTC date and the Warsaw date are the same date.&lt;/p&gt;

&lt;p&gt;The integration had been live for a month. Invoices had been arriving one or two at a time, whenever someone signed up, and every one of them was right. Then the monthly billing run went out at exactly the wrong minute, and 328 invoices landed together in the wrong month.&lt;/p&gt;

&lt;p&gt;The failure mode was invisible until it hit at scale, at the only time of day that triggers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong day vs wrong month
&lt;/h2&gt;

&lt;p&gt;An off-by-one day on a date field is usually a cosmetic bug. This one was not, for a boring reason: 31 August and 1 September are in different VAT periods.&lt;/p&gt;

&lt;p&gt;These invoices go to Poland's national e-invoicing system. Once an invoice is filed there it cannot be edited or withdrawn. The only remedy is issuing a correcting invoice afterwards. So the difference between "wrong day" and "wrong month" is the difference between a typo and a tax filing in the wrong period.&lt;/p&gt;

&lt;p&gt;The 328 had not been filed yet. That was luck, not design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the wrong rows without guessing
&lt;/h2&gt;

&lt;p&gt;I could have selected everything dated 31 August and moved it forward one day. That would also have moved genuinely-August invoices, because 31 August is a real date on which real invoices exist.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is to use the arrival time: if a row arrived on 1 September and is dated 31 August, fix it. That is wrong too. An invoice legitimately issued at 23:58 on 31 August, whose webhook landed at 00:01, is correctly dated and would be corrupted by the fix.&lt;/p&gt;

&lt;p&gt;What saved me was a second, independent signal. The billing platform's own invoice numbers carry the month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PSA-09-2026-17
     ^^ September
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the rule became: correct a row only when the invoice number says September &lt;strong&gt;and&lt;/strong&gt; the row arrived on 1 September Warsaw time &lt;strong&gt;and&lt;/strong&gt; the stored date is exactly one day earlier. Three facts agreeing, two of them from the source system rather than from my own inference.&lt;/p&gt;

&lt;p&gt;That found 328 rows, zero ambiguous ones, and left the 23:58 case alone. After the fix, all 490 invoices agreed with the platform's own numbering, where 328 had not.&lt;/p&gt;

&lt;p&gt;If you are writing a data repair script, find the second signal before you write the UPDATE. The one-signal version would have quietly broken correct rows while looking like it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; with an explicit time zone. The &lt;code&gt;sv-SE&lt;/code&gt; locale is the convenient one here because its date format is already &lt;code&gt;YYYY-MM-DD&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WARSAW_YMD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sv-SE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Europe/Warsaw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;polishDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;WARSAW_YMD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the formatter once outside the function. &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; construction is not free, and this runs per invoice.&lt;/p&gt;

&lt;p&gt;Do not be tempted by the arithmetic version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong six months a year&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Poland is UTC+2 in summer and UTC+1 in winter. A hardcoded offset is correct until the last Sunday in October, then silently wrong until March. &lt;code&gt;Intl&lt;/code&gt; reads the timezone database, so DST is handled for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that catches it
&lt;/h2&gt;

&lt;p&gt;Two cases, and the second is the one that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2026-09-01 00:00 Europe/Warsaw (22:00 UTC on 31 August)&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;polishDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1788213600&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-09-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2026-01-01 00:00 Europe/Warsaw (23:00 UTC on 31 December), UTC+1&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;polishDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1767222000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-01-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The January case fails against a hardcoded &lt;code&gt;+2&lt;/code&gt;. Without it, the fix looks correct for six months.&lt;/p&gt;

&lt;p&gt;I also reverted the fix and re-ran the tests, to confirm they actually caught the original bug. A test you have never seen fail is a test you have not written yet. Mine passed on the first run before I checked, which is exactly when I should have been suspicious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;toISOString()&lt;/code&gt; is a timestamp serializer. It is not a date formatter. It answers "what is this instant in UTC", and if you slice the first ten characters off it, you have asserted that UTC is the calendar your users live in.&lt;/p&gt;

&lt;p&gt;That is fine for a log line. It is not fine when the date has meaning to somebody: a tax period, an invoice date, a delivery date, a contract term, a birthday.&lt;/p&gt;

&lt;p&gt;Three questions worth asking about any date field in your system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does this date belong to a place?&lt;/strong&gt; An issue date on a Polish invoice is a Polish calendar date, no matter where the server or the billing platform is. If the answer is yes, the timezone is part of the data, not a display concern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens near midnight?&lt;/strong&gt; Every timezone bug lives between 00:00 and the UTC offset. If your tests use midday timestamps, they will all pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens in the other season?&lt;/strong&gt; Half the year is DST. Test in January and in July.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The customer found this one before we did. She had been sending invoices through the system for six weeks, noticed the dates were a day out, and told us the same morning. That is a good customer, and it is not a substitute for a test at midnight.&lt;/p&gt;

&lt;p&gt;I hit this building &lt;a href="https://fakturaflow.pl/blog/masowa-wysylka-faktur-ksef-praktyka" rel="noopener noreferrer"&gt;FakturaFlow&lt;/a&gt;, which files invoices to Poland's e-invoicing system in bulk. That link goes to a longer write-up of what else breaks at a thousand invoices a month, in Polish, since that is who has to deal with it.&lt;/p&gt;

&lt;p&gt;If you convert a timestamp to a date anywhere in your codebase, it is worth ten minutes with grep today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"toISOString().slice(0, 10)"&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That found eight more in mine. Seven turned out to be fine: a download filename, today's date in a prompt, a test fixture. Being a day out in a filename costs nothing.&lt;/p&gt;

&lt;p&gt;The one I am still looking at is a central bank exchange-rate lookup, because the rate published "for 1 September" is a Polish calendar date too, and asking for the wrong day returns the wrong number rather than an error.&lt;/p&gt;

&lt;p&gt;That is the test. Not "is this UTC", but &lt;strong&gt;who cares what day this is, and what does it cost them if it is wrong&lt;/strong&gt;. Most of the time, nobody. Occasionally, a tax authority.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>pdf-lib throws on Ł. The obvious fix silently deletes your users' data</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Sun, 30 Aug 2026 08:26:50 +0000</pubDate>
      <link>https://dev.to/stackedboost/pdf-lib-is-silently-deleting-characters-from-your-users-data-51ld</link>
      <guid>https://dev.to/stackedboost/pdf-lib-is-silently-deleting-characters-from-your-users-data-51ld</guid>
      <description>&lt;p&gt;I generate invoices in a Node serverless function with &lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt;. It is a good library, and to its credit it did the right thing: it threw.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: WinAnsi cannot encode "Ł" (0x0141)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client was called &lt;code&gt;Łódź Sp. z o.o.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I did what almost everyone does when a document generator throws in production: I stripped the character that was upsetting it. The exception stopped. The tests went green. And the invoice then said &lt;code&gt;ód Sp. z o.o.&lt;/code&gt;, on a document my user was about to send to their own customer, with nothing in the logs to suggest anything had happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pdf-lib is not the thing being quiet here. My fix was.&lt;/strong&gt; That distinction took me longer to see than it should have, and it is the whole point of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard 14 fonts are Latin-1 only
&lt;/h2&gt;

&lt;p&gt;PDF has fourteen fonts every reader is guaranteed to have, so you can use them without embedding anything. pdf-lib exposes them as &lt;code&gt;StandardFonts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StandardFonts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf-lib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;StandardFonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Helvetica&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the default path in every tutorial, and it is fine until it is not. Those fonts are encoded as &lt;strong&gt;WinAnsi&lt;/strong&gt; (roughly Windows-1252). Their glyph set covers Western European Latin and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Ł&lt;/code&gt; and &lt;code&gt;ź&lt;/code&gt; are not in it. I checked the boundary rather than guessing, and it is narrower than people assume:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Standard font&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;€&lt;/code&gt; &lt;code&gt;£&lt;/code&gt; &lt;code&gt;’&lt;/code&gt; &lt;code&gt;–&lt;/code&gt; &lt;code&gt;—&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;draws fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ř&lt;/code&gt; (Czech)&lt;/td&gt;
&lt;td&gt;throws&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ı&lt;/code&gt; (Turkish)&lt;/td&gt;
&lt;td&gt;throws&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Москва&lt;/code&gt; (Cyrillic)&lt;/td&gt;
&lt;td&gt;throws&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;株式会社&lt;/code&gt; (CJK)&lt;/td&gt;
&lt;td&gt;throws&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the euro sign and curly quotes are safe, which is the part most people worry about. Names are not, which is the part that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it dangerous is the fix, not the error
&lt;/h2&gt;

&lt;p&gt;The exception above is correct and helpful. It is also loud, and loud things get silenced. So people do the obvious thing to stop the crash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// don't do this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^\x&lt;/span&gt;&lt;span class="sr"&gt;20-&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="sr"&gt;FF&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did exactly this. It stops the exception, the code goes green, and every test passes. What it actually does is delete parts of your users' data from a document they are about to send to their own customer.&lt;/p&gt;

&lt;p&gt;There is no error to notice. The PDF is valid. Nothing in your logs suggests anything happened. The only way to find out is to render a page with non-Latin-1 text in it and look at it with your eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: embed a real font
&lt;/h2&gt;

&lt;p&gt;Embed a TrueType or OpenType face and the problem disappears, because you are no longer restricted to a 1990s encoding table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fontkit&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@pdf-lib/fontkit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf-lib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerFontkit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fontkit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// required, and easy to forget&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets/fonts/Archivo-Regular.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;subset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Łódź Sp. z o.o.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;registerFontkit&lt;/code&gt; is mandatory.&lt;/strong&gt; Without it &lt;code&gt;embedFont&lt;/code&gt; on a byte array throws a message about fontkit that does not obviously connect to your problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;subset: true&lt;/code&gt; matters more than you think.&lt;/strong&gt; A full Archivo weight is about 180 KB. Embedding three weights unsubsetted adds half a megabyte to every single document. With subsetting, my four-page invoice with three weights comes out at 35 KB, because only the glyphs actually used get embedded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache the file reads.&lt;/strong&gt; In a serverless function, &lt;code&gt;readFile&lt;/code&gt; on every invocation is wasted latency. Read once into a module-level variable and reuse it across warm invocations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadFonts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fonts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;regular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;semi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;black&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Archivo-Regular.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Archivo-SemiBold.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Archivo-Black.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;regular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;semi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;black&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check the licence before you vendor a font. Archivo is SIL OFL, which permits embedding; plenty of commercial faces do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  An embedded font still is not every glyph
&lt;/h2&gt;

&lt;p&gt;Embedding solves the encoding problem, not the coverage problem. Archivo has no CJK. If a user types a Japanese company name, &lt;code&gt;widthOfTextAtSize&lt;/code&gt; throws on that character and you are back where you started, just further along.&lt;/p&gt;

&lt;p&gt;So keep a fallback, but make it visible rather than silent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;drawable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;widthOfTextAtSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;          &lt;span class="c1"&gt;// a gap you can see, not a deletion you cannot&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A space is not a great outcome. It is a much better outcome than a name silently closing up, because a human proofreading the document has a chance of spotting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things only rendering will tell you
&lt;/h2&gt;

&lt;p&gt;While I had a rasteriser pointed at the output, two layout bugs turned up that no amount of reading the code would have found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A six-figure total ran backwards over its own label.&lt;/strong&gt; The totals block right-aligned the figure at 20pt Black in a column sized for four digits. At &lt;code&gt;$2,160.00&lt;/code&gt; it was fine. At &lt;code&gt;86,832.81 PLN&lt;/code&gt; it overlapped the words &lt;code&gt;TOTAL DUE&lt;/code&gt; sitting to its left. Text does not wrap or complain when it is drawn at an absolute coordinate; it just draws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A long company name ran off the right edge of the page.&lt;/strong&gt; Same cause. &lt;code&gt;drawText&lt;/code&gt; has no concept of a container, so anything longer than you imagined simply continues past the paper.&lt;/p&gt;

&lt;p&gt;Both are obvious in a rendered image and invisible in a diff. If you generate PDFs, put a rasteriser in your test loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PyMuPDF, no system dependencies&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import pymupdf
pymupdf.open('out.pdf')[0].get_pixmap(dpi=120).save('out.png')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then actually open the PNG. It takes ten seconds and it is the only test that catches this class of bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The standard 14 fonts are WinAnsi. They cannot draw most of the world's names.&lt;/li&gt;
&lt;li&gt;Stripping the characters to stop the exception silently corrupts user data in a document they will send to a third party.&lt;/li&gt;
&lt;li&gt;Embed a subsetted TrueType face with &lt;code&gt;@pdf-lib/fontkit&lt;/code&gt;, cache the bytes, and check the licence.&lt;/li&gt;
&lt;li&gt;Keep a fallback that leaves a visible gap rather than a clean deletion.&lt;/li&gt;
&lt;li&gt;Render your output to an image in tests and look at it. Absolute-positioned text overlaps and overflows without any error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I found all of this building &lt;a href="https://hourtobill.com" rel="noopener noreferrer"&gt;HourToBill&lt;/a&gt;, where the invoice is the entire product, so a mangled client name is not a cosmetic bug. If you generate documents from anything a user typed, it is worth spending twenty minutes checking what yours does with &lt;code&gt;Łódź&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We ran the same ChatGPT prompt three times in a row. Here is what changed.</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Tue, 18 Aug 2026 20:38:23 +0000</pubDate>
      <link>https://dev.to/stackedboost/we-ran-the-same-chatgpt-prompt-three-times-in-a-row-here-is-what-changed-349k</link>
      <guid>https://dev.to/stackedboost/we-ran-the-same-chatgpt-prompt-three-times-in-a-row-here-is-what-changed-349k</guid>
      <description>&lt;p&gt;We wanted to know how much a single ChatGPT answer can be trusted as a measurement. So we ran the same prompt through ChatGPT more than once, with nothing changed between runs, and recorded what came back each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;We used DataForSEO's ChatGPT LLM Scraper on 2026-08-16. For each prompt we ran the exact same query three times, minutes apart, same settings every time. Nothing about the prompt or the account changed between runs. Any difference in the answers is the model, not us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Email marketing apps for Shopify
&lt;/h2&gt;

&lt;p&gt;The prompt was "best email marketing app for Shopify," run three times.&lt;/p&gt;

&lt;p&gt;Across the three runs, 16 distinct domains showed up somewhere in the answers. Only 4 of those 16 appeared in all three runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Brand&lt;/th&gt;
&lt;th&gt;Runs appeared in&lt;/th&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Klaviyo&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;1st, every time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Omnisend&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;2nd, every time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailchimp&lt;/td&gt;
&lt;td&gt;2 of 3&lt;/td&gt;
&lt;td&gt;4th twice, absent once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Klaviyo and Omnisend held their spots exactly. Mailchimp did not. It sat at position 4 in two runs and was missing from the answer entirely in the third. Nothing about the prompt changed between that run and the other two.&lt;/p&gt;

&lt;p&gt;If you had only run this prompt once, and it happened to be the run without Mailchimp, you would have concluded Mailchimp does not appear for this query. Run it again and that conclusion is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  A wider check: HTML-to-PDF APIs
&lt;/h2&gt;

&lt;p&gt;To see if this held up outside one prompt, we ran 4 prompts about the HTML-to-PDF API category, 3 runs each, for 12 observations total.&lt;/p&gt;

&lt;p&gt;66 distinct domains showed up across those 12 observations. Only 5 domains appeared in every run of the specific prompt they showed up on. 4 of 8 brands we were tracking appeared in some runs of a prompt and were missing from others.&lt;/p&gt;

&lt;p&gt;The pattern from the email marketing prompt was not a one-off. The names at the top of an answer tend to hold their position. Everything below that is less settled than a single run makes it look.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost to check
&lt;/h2&gt;

&lt;p&gt;Running all 12 observations for the HTML-to-PDF category cost $0.004 per call, $0.048 in total. Checking whether an answer is stable is not expensive. Not checking is the part that costs something, because it produces a wrong number with the same confidence as a right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conclusion
&lt;/h2&gt;

&lt;p&gt;A tool that runs a prompt once and reports "brand visibility: 67%" is reporting a single sample as if it were a measurement. Based on what we saw here, that number would land differently depending on which of the three runs happened to be sampled.&lt;/p&gt;

&lt;p&gt;The top of a ranking is stable. Klaviyo held position 1 in every run. Omnisend held position 2 in every run. That part, one run would have told you correctly.&lt;/p&gt;

&lt;p&gt;The marginal brand is not stable. Mailchimp came and went. In the wider check, more than half the brands we tracked were inconsistent from run to run. That instability sits exactly where most brands actually are, not at the top, and it is exactly what a business paying for this kind of tracking wants to know: are we in or out, and how often.&lt;/p&gt;

&lt;p&gt;A single run cannot answer that question. It can only tell you what happened once.&lt;/p&gt;




&lt;p&gt;Measured on 2026-08-16 using DataForSEO's ChatGPT LLM Scraper. Same prompts, same settings, three runs each, minutes apart.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>chatgpt</category>
      <category>data</category>
    </item>
    <item>
      <title>I measured 7,032 WordPress plugins to find out how anyone gets their first install</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Sun, 16 Aug 2026 18:24:12 +0000</pubDate>
      <link>https://dev.to/stackedboost/i-measured-7032-wordpress-plugins-to-find-out-how-anyone-gets-their-first-install-g4m</link>
      <guid>https://dev.to/stackedboost/i-measured-7032-wordpress-plugins-to-find-out-how-anyone-gets-their-first-install-g4m</guid>
      <description>&lt;p&gt;I shipped a plugin to the WordPress.org directory. It got zero installs.&lt;/p&gt;

&lt;p&gt;That is not a complaint, it is the normal outcome. Roughly &lt;strong&gt;19% of all plugins in the directory never pass zero installs&lt;/strong&gt;, which is more than 10,500 of them. But I wanted to know &lt;em&gt;why&lt;/em&gt;, and whether the answer was "your plugin is bad" or something structural.&lt;/p&gt;

&lt;p&gt;So instead of reading marketing advice, I queried the directory API and counted.&lt;/p&gt;

&lt;p&gt;Everything below is reproducible. The API is free, needs no key, and every query I used is in the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Search is a &lt;strong&gt;two phase&lt;/strong&gt; system, and phase one is a &lt;strong&gt;hard filter&lt;/strong&gt;, not a ranking. If a single word of the user's query is missing from your listing, you are excluded from that search entirely.&lt;/li&gt;
&lt;li&gt;Phase two is where you lose, and it is ranked partly on &lt;strong&gt;active installs&lt;/strong&gt;. That is the cold start trap.&lt;/li&gt;
&lt;li&gt;Of the plugins that broke out recently, &lt;strong&gt;88% had distribution before they started&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The two behaviours that actually correlate with breaking out from nothing are &lt;strong&gt;release cadence&lt;/strong&gt; and &lt;strong&gt;resolving support threads&lt;/strong&gt;, which are two of the five phase-two ranking inputs and the only two a plugin with no installs can move.&lt;/li&gt;
&lt;li&gt;WordPress.org gives plugin authors &lt;strong&gt;no analytics whatsoever&lt;/strong&gt;. No listing views, no impressions, no click-through. Anyone who tells you confidently what makes people click install is guessing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How search actually works
&lt;/h2&gt;

&lt;p&gt;The best-documented account traces to WP Tavern's 2017 coverage of the directory relaunch, quoting Greg Brown, the Automattic data engineer who built it. It runs on Elasticsearch, and it has two phases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase one builds the candidate pool.&lt;/strong&gt; It matches against title, excerpt, description, tags, slug, author name and contributor names. Critically: all search keywords must appear somewhere, or the plugin is excluded from the result set. Not ranked low. Excluded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase two sorts that pool&lt;/strong&gt; by last update date, compatibility with the current core version, active installs, percent of support tickets resolved, and average rating.&lt;/p&gt;

&lt;p&gt;That split matters more than anything else in this article, because phase one is entirely yours and phase two mostly is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing the hard filter on my own plugin
&lt;/h3&gt;

&lt;p&gt;I took my live plugin and measured its actual position for terms it should rank for, then checked which query words appear in its listing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rank&lt;/th&gt;
&lt;th&gt;term&lt;/th&gt;
&lt;th&gt;missing words&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;disposable email blocker&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;block disposable email&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;disposable email&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;td&gt;temporary email&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&amp;gt;300&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;email validation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&amp;gt;300&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;spam registration&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;"spam"&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two failures, two completely different causes.&lt;/p&gt;

&lt;p&gt;"spam registration" fails because the word &lt;strong&gt;spam&lt;/strong&gt; appears nowhere in the listing. That is phase one. It is a hard exclusion, and it is free to fix.&lt;/p&gt;

&lt;p&gt;"email validation" fails even though every word is present. That is phase two: the term is owned by WP Mail SMTP and CleanTalk on install count, and no amount of rewriting changes it.&lt;/p&gt;

&lt;p&gt;Being able to tell those two apart is the single most useful thing I learned. One is a typo-level fix. The other is not a copy problem at all.&lt;/p&gt;

&lt;p&gt;Here is the query, if you want to check your own. Note the &lt;code&gt;-g&lt;/code&gt; flag, without it your shell will try to glob the square brackets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wordpress.org/plugins/info/1.2/?action=query_plugins&amp;amp;request[search]=disposable+email&amp;amp;request[per_page]=100&amp;amp;request[page]=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Who actually breaks out
&lt;/h2&gt;

&lt;p&gt;This is the part I expected to be discouraging, and it was worse than I expected.&lt;/p&gt;

&lt;p&gt;I swept the entire directory for plugins with &lt;strong&gt;1,000 or more active installs&lt;/strong&gt;. That is 7,032 plugins. Then I filtered to those &lt;strong&gt;added in the last 18 months&lt;/strong&gt;. That leaves 167. Those are the breakouts.&lt;/p&gt;

&lt;p&gt;Then I looked at who published them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;cause&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Author already ran another plugin with real installs&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Known commercial brand with an audience off WordPress&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-promoted from the author's own theme or membership site&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy plugin name revived by a directory insider&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Company formed specifically to ride a trend&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unclear, possible genuine cold start&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;88% had some form of pre-existing distribution.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The scale of the first group is worth spelling out. One vendor launched eight breakouts off the back of a two million install plugin. Another launched three off ten million. That is not a growth tactic you can copy, it is an asset you either have or do not.&lt;/p&gt;

&lt;p&gt;The second group is the one that fooled me at first. It looks like cold starts, because those authors have no other WordPress plugins. But it is Visa. It is Sendcloud. It is a hosting company. These are businesses with a customer base &lt;strong&gt;off&lt;/strong&gt; WordPress, shipping an integration to people who already pay them. Same advantage, different platform.&lt;/p&gt;

&lt;p&gt;When I dug into the remainder, &lt;strong&gt;two&lt;/strong&gt; out of sixty-nine survived as genuine unknown-author cold starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  What those two did
&lt;/h3&gt;

&lt;p&gt;They took opposite routes, and the difference is instructive.&lt;/p&gt;

&lt;p&gt;One is a floating WhatsApp button. Its author has exactly one plugin in the entire directory and no brand at all. It serves an extremely common, low-friction search intent that the directory's own search surfaces well.&lt;/p&gt;

&lt;p&gt;The other is an &lt;code&gt;llms.txt&lt;/code&gt; generator. It broke out because &lt;strong&gt;llms.txt did not exist before 2025&lt;/strong&gt;. There were no incumbents with an install-count advantage, so phase two was level and phase one decided.&lt;/p&gt;

&lt;p&gt;That second one is the only mechanism I found that &lt;em&gt;beats&lt;/em&gt; the cold start loop rather than enduring it. When the need is new, nobody has installs yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grind that does work
&lt;/h2&gt;

&lt;p&gt;I filtered the breakouts down to solo developers shipping security and blocking utilities, which is my own category, and looked at their behaviour rather than their marketing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;plugin&lt;/th&gt;
&lt;th&gt;installs&lt;/th&gt;
&lt;th&gt;age&lt;/th&gt;
&lt;th&gt;releases&lt;/th&gt;
&lt;th&gt;support resolved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Advanced IP Blocker&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;14 mo&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;124&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vigilant&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;6 mo&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15 of 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BotBlocker&lt;/td&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;td&gt;9 mo&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo Blocker&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;15 mo&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 of 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Advanced IP Blocker shipped &lt;strong&gt;124 releases in 14 months&lt;/strong&gt;. That is roughly nine a month. Vigilant shipped 64 in six. Both resolved every single support thread they received, both have the most ratings in the group, and both had shipped within days of when I measured.&lt;/p&gt;

&lt;p&gt;Geo Blocker is the control case. Two releases, never touched again, one support thread left unanswered. Still at the bottom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is not just correlation.&lt;/strong&gt; Last update date and percent of support tickets resolved are two of the five documented phase-two ranking inputs. The third input, active installs, is the one you cannot move. So shipping often and answering everything are not merely habits of successful plugins, they are literally the only two ranking levers available to a plugin with no users.&lt;/p&gt;

&lt;p&gt;One more thing I noticed and cannot prove causal: those breakout readmes run 39,000 to 42,000 characters. Mine were 8,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that are simply not true
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"0 active installs" does not mean zero.&lt;/strong&gt; A WordPress core issue confirms the label really means "fewer than 10". If you are sitting at zero, you may well have users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot measure your funnel.&lt;/strong&gt; WordPress.org exposes no listing views, no search impressions, no click-through rate, nothing comparable to App Store Connect or the Play Console. So when someone tells you that a better banner or a shorter description improves conversion, ask where the number came from. The platform does not hand anybody that data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not pay for reviews or installs.&lt;/strong&gt; It is Guideline 9. The penalty is removal of all your reviews, then removal of the plugin on a repeat. There is a real enforcement case.&lt;/p&gt;

&lt;h2&gt;
  
  
  An API trap that cost me an hour
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;request[browse]=popular&lt;/code&gt; &lt;strong&gt;silently ignores &lt;code&gt;request[tag]&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I confirmed it across four different tags, all returning identical results. There is no error and no warning. If you are trying to sweep a category by popularity, you cannot do it in one call. Sweep by tag without &lt;code&gt;browse&lt;/code&gt;, or sweep &lt;code&gt;browse=popular&lt;/code&gt; globally and filter client-side.&lt;/p&gt;

&lt;p&gt;Also useful: &lt;code&gt;query_plugins&lt;/code&gt; returns the &lt;code&gt;added&lt;/code&gt; date in the bulk response, so you can find recently-published plugins without a detail call per plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;I stopped treating this as a marketing problem.&lt;/p&gt;

&lt;p&gt;I audited every listing for phase-one coverage, checking that every word of each target search literally appears. That found a real gap: one of my plugins says "blocklist" everywhere, while merchants type "blacklist". Free to fix, and it was excluding me from those searches entirely.&lt;/p&gt;

&lt;p&gt;I built a rank tracker, because installs are the last thing to move and search position is the first. Without it I was editing copy blind. If rank improves after a wording change, that is phase one responding. If it refuses to move, that is phase two, and no rewrite will fix it.&lt;/p&gt;

&lt;p&gt;And I stopped picking categories with entrenched incumbents. The &lt;code&gt;llms.txt&lt;/code&gt; case is the whole lesson: when a requirement is brand new, everybody starts at zero installs together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest conclusion
&lt;/h2&gt;

&lt;p&gt;If you have an audience, use it, because that is what 88% of successful launches did.&lt;/p&gt;

&lt;p&gt;If you do not, the directory is not going to discover you on its own. The two levers you own are shipping often and answering every support thread. Both are slow. The most rigorously documented zero-audience case I could find took about a year to reach a thousand installs, at roughly a hundred a month, and the developer says plainly it was never exponential.&lt;/p&gt;

&lt;p&gt;The alternative is to be early to something new enough that nobody has an install-count advantage yet. That is the only shortcut in the data.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of building four plugins for WordPress.org, one of which is live so far and sitting at the install count this article is about, which is rather the point. The one that prompted the research is &lt;a href="https://stackedboost.com/woocommerce/stackedboost-email-guard/" rel="noopener noreferrer"&gt;Email Guard&lt;/a&gt;, a local disposable-email blocker for WooCommerce that bundles its domain list and makes no outbound requests. Every number above is reproducible from the public plugin API.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>marketing</category>
    </item>
    <item>
      <title>If you render user HTML, you have an SSRF problem. Here is the code that fixes it.</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Sat, 15 Aug 2026 11:57:08 +0000</pubDate>
      <link>https://dev.to/stackedboost/if-you-render-user-html-you-have-an-ssrf-problem-here-is-the-code-that-fixes-it-15e8</link>
      <guid>https://dev.to/stackedboost/if-you-render-user-html-you-have-an-ssrf-problem-here-is-the-code-that-fixes-it-15e8</guid>
      <description>&lt;p&gt;Every HTML-to-PDF service has the same shape. Someone hands you markup, you open it in headless Chromium, you print to PDF. If you want Mermaid diagrams or KaTeX math to work, that markup has to be allowed to run JavaScript.&lt;/p&gt;

&lt;p&gt;Which means you have built a machine that fetches arbitrary URLs from inside your network, on request, for strangers.&lt;/p&gt;

&lt;p&gt;Most write-ups stop at "block private IPs". That is the easy half. The hard half is that the URL your user submits is not the only URL your service requests, and an IP address has more spellings than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request you validated is not the request you make
&lt;/h2&gt;

&lt;p&gt;Say you accept a URL and check it before rendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isPrivateIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nope&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check covers exactly one request. The page you just loaded can issue as many more as it likes, and none of them went through your validator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"http://169.254.169.254/latest/meta-data/iam/security-credentials/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:6379&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; 
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image fetch reaches your cloud metadata endpoint. The &lt;code&gt;fetch&lt;/code&gt; hits Redis on the loopback address. A redirect from a public URL to an internal one bypasses the check too, because you validated the URL before the redirect existed.&lt;/p&gt;

&lt;p&gt;So you need two layers, and the second one is the one that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRequestInterception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAllowedRequestUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every subresource, every redirect hop, every XHR. Chromium asks permission before each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS rebinding, and the asymmetry that beats it
&lt;/h2&gt;

&lt;p&gt;Now the classic attack. Your validator resolves &lt;code&gt;evil.example&lt;/code&gt; and gets a public address, so it passes. Chromium then resolves it again to make the actual request, and this time the record answers &lt;code&gt;127.0.0.1&lt;/code&gt;. Two lookups, two answers, and the gap between them is the vulnerability.&lt;/p&gt;

&lt;p&gt;You cannot fully close that gap without pinning the resolved address at the socket layer. You can narrow it a lot, and there is one design decision that does most of the work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache refusals. Never cache approvals.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLOCKED_TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like a performance detail. It is the security property. If you cache "this host is public" for even sixty seconds, an attacker answers public once, gets that verdict cached, and then points the record wherever they like for the rest of the window. Your cache is now defeating your own validator.&lt;/p&gt;

&lt;p&gt;Caching the refusal is safe, because the cached answer is the "no". A host that becomes benign later is simply blocked slightly longer than necessary, and nobody is harmed by that.&lt;/p&gt;

&lt;p&gt;The same instinct applies everywhere else in this code. When you cannot tell, refuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addrs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;isPrivate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;addrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;addrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;isPrivateIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;isPrivate&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;   &lt;span class="c1"&gt;// a name that will not resolve is refused, not passed through&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A URL that will not even parse gets the same treatment. It returns &lt;code&gt;false&lt;/code&gt;, not &lt;code&gt;true&lt;/code&gt;. The schemes that legitimately arrive here (&lt;code&gt;data:&lt;/code&gt;, &lt;code&gt;about:&lt;/code&gt;, &lt;code&gt;blob:&lt;/code&gt;) all parse fine, so a parse failure is genuinely suspicious.&lt;/p&gt;

&lt;p&gt;And allowlist the schemes rather than blocklisting them. &lt;code&gt;ftp:&lt;/code&gt;, &lt;code&gt;ws:&lt;/code&gt; and &lt;code&gt;chrome-extension:&lt;/code&gt; have no business in a document you were asked to render, and you will never think of all of them in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the part that actually bites: parsing
&lt;/h2&gt;

&lt;p&gt;Here is where I lost the most time, and where I suspect most implementations are quietly wrong. Every one of these was a real bug in my own code, found by writing the test suite rather than by reading it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;parseInt&lt;/code&gt; is not a validator.&lt;/strong&gt; It takes a valid prefix and ignores the rest. &lt;code&gt;parseInt("1g", 16)&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt;, not an error. So &lt;code&gt;::1g&lt;/code&gt; parses as &lt;code&gt;::1&lt;/code&gt;, or worse, as something that passes a check it should have failed. Validate the shape first, parse second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{1,4}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hextet&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Anchor your dotted quad.&lt;/strong&gt; IPv6 allows a trailing IPv4 form, &lt;code&gt;::ffff:127.0.0.1&lt;/code&gt;. If you match that suffix without anchoring both ends, then &lt;code&gt;::ffff:1.2.3.4.5&lt;/code&gt;, &lt;code&gt;::ffff:1.2.3&lt;/code&gt; and &lt;code&gt;::ffff:1.2.3.4x&lt;/code&gt; all get accepted, because your regex happily consumes the part it likes and ignores the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reject zone indices, do not strip them.&lt;/strong&gt; Node considers &lt;code&gt;fe80::1%eth0&lt;/code&gt; a valid IPv6 address. It is tempting to strip the &lt;code&gt;%eth0&lt;/code&gt; and carry on. Do not. A scoped address is not something a web page should ever be fetching, and stripping a suffix is how malformed input becomes valid input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judge mapped addresses by what they embed.&lt;/strong&gt; &lt;code&gt;::ffff:127.0.0.1&lt;/code&gt; and &lt;code&gt;::ffff:7f00:1&lt;/code&gt; are the same address wearing different clothes. Compare the bytes, not the string, or you will block one spelling and wave the other through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NAT64 is not where you think it is.&lt;/strong&gt; The translation prefix is &lt;code&gt;64:ff9b::/96&lt;/code&gt;. The first hextet is &lt;code&gt;0x0064&lt;/code&gt;, so the significant bytes sit at offsets 1 to 3, not 0 to 2. Get this off by one and you block nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teredo needs all four bytes.&lt;/strong&gt; The prefix is &lt;code&gt;2001:0000::/32&lt;/code&gt;. I originally checked three bytes, which was worse than useless: it blocked &lt;code&gt;2001:0001::/32&lt;/code&gt; and everything else under &lt;code&gt;2001:00xx&lt;/code&gt;, which are ordinary public addresses. An over-broad rule is still a bug, it just fails in a direction nobody reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strip the brackets.&lt;/strong&gt; WHATWG &lt;code&gt;URL&lt;/code&gt; keeps them: &lt;code&gt;new URL("http://[::1]/").hostname&lt;/code&gt; is &lt;code&gt;"[::1]"&lt;/code&gt;, and Node's &lt;code&gt;isIP()&lt;/code&gt; rejects the bracketed form. Miss this and every IPv6 literal falls through to a DNS lookup that fails, gets refused, and looks like it works. It is correct by accident for private addresses, wrong for public ones, and it will break the day you change your fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;It narrows the rebinding window. It does not eliminate it. Fully closing it means resolving the name once and pinning that address at the socket layer so the request cannot be redirected to a different one. That is not implemented here, and I would rather say so than let you assume otherwise.&lt;/p&gt;

&lt;p&gt;It also assumes your renderer is not otherwise sandboxed. Network policy at the container level, a blocked metadata endpoint, and an egress allowlist are all better defences than anything above, because they do not depend on my parser being correct. Treat this as the layer that catches what those miss, not as the only thing standing between a stranger and your instance metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth stealing
&lt;/h2&gt;

&lt;p&gt;If you take one thing: write the test suite before you trust the parser. Mine has thirty-one cases for the URL layer and forty-five assertions for IPv6 alone, and it found four real bugs in code I had already read twice and believed was correct. Address parsing is exactly the kind of problem where reading proves nothing, because every bug looks reasonable on the page.&lt;/p&gt;

&lt;p&gt;The code above runs in &lt;a href="https://mintpdf.dev" rel="noopener noreferrer"&gt;MintPDF&lt;/a&gt;, a Markdown and HTML to PDF API. It is MIT licensed, so the full implementation is &lt;a href="https://github.com/TrendTweekers/mintpdf" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; if you want the rest of it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Integrating with Poland's KSeF: five things that aren't in the docs</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Sat, 15 Aug 2026 08:39:33 +0000</pubDate>
      <link>https://dev.to/stackedboost/integrating-with-polands-ksef-five-things-that-arent-in-the-docs-4mff</link>
      <guid>https://dev.to/stackedboost/integrating-with-polands-ksef-five-things-that-arent-in-the-docs-4mff</guid>
      <description>&lt;p&gt;Poland switched to mandatory structured e-invoicing this year. Since 1 February 2026 for the largest companies, since 1 April 2026 for all active VAT payers, and from 1 January 2027 for everyone else, including the smallest and VAT-exempt.&lt;/p&gt;

&lt;p&gt;The system is called KSeF (Krajowy System e-Faktur). You send an invoice as XML in a schema called FA(3), it validates it, assigns it a number, and returns a signed receipt. Conceptually simple. There is an OpenAPI spec and official SDKs in C# and Java.&lt;/p&gt;

&lt;p&gt;I've spent this year building an integration against it in TypeScript on Deno, and it now files invoices in production. Below are five things that cost me real time and are not in the documentation, or are in it in a way you only recognise afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The receipt is issued per invoice, but fetched through the session
&lt;/h2&gt;

&lt;p&gt;This is the one I'd most want to tell my past self, and it has two halves that are easy to get backwards.&lt;/p&gt;

&lt;p&gt;You can send invoices in a batch session: one ZIP of XML files, hundreds at a time. The session returns a reference number, and it's tempting to store that and call the invoices delivered.&lt;/p&gt;

&lt;p&gt;It isn't. The document that legally proves an invoice reached the system is the UPO (&lt;em&gt;Urzędowe Poświadczenie Odbioru&lt;/em&gt;), and &lt;strong&gt;it exists per invoice&lt;/strong&gt;. A session reference proves a session happened. If you are ever asked to produce evidence for one specific invoice, that isn't it.&lt;/p&gt;

&lt;p&gt;But — and this is the half I got wrong first — the UPO is &lt;strong&gt;retrieved through the session that submitted it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v2/sessions/{originalSessionRef}/invoices/ksef/{ksefNumber}/upo
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the session reference isn't disposable, it's a required key. An earlier version of my code opened a &lt;em&gt;fresh&lt;/em&gt; session to fetch UPOs and could never resolve them, because KSeF binds UPO metadata to the original session. If you lose that reference, retrieving the receipt afterwards gets considerably harder.&lt;/p&gt;

&lt;p&gt;Practically: store the session reference &lt;strong&gt;and&lt;/strong&gt; iterate per invoice. A batch of 200 means one upload and then 200 fetches, all keyed on that one session ref. Budget for it in your queue design — it dominates wall-clock time, not the upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One bad invoice doesn't fail the batch
&lt;/h2&gt;

&lt;p&gt;The inverse mistake. A document that fails semantic validation is rejected &lt;strong&gt;individually&lt;/strong&gt;. The other 199 in the same session go through normally.&lt;/p&gt;

&lt;p&gt;If you treat a batch as atomic and roll the whole thing back on any error, you'll re-send invoices that were already accepted, and duplicate invoice numbers are their own category of pain. Per-invoice status tracking isn't optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Not every buyer has a tax ID, and it isn't only consumers
&lt;/h2&gt;

&lt;p&gt;FA(3)'s buyer section (&lt;code&gt;Podmiot2&lt;/code&gt;) offers a choice of identifiers. One of them is &lt;code&gt;BrakID&lt;/code&gt;, literally "no ID":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;fa:Podmiot2&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;fa:DaneIdentyfikacyjne&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;fa:BrakID&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/fa:BrakID&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;fa:Nazwa&amp;gt;&lt;/span&gt;Jan Kowalski&lt;span class="nt"&gt;&amp;lt;/fa:Nazwa&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/fa:DaneIdentyfikacyjne&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/fa:Podmiot2&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I read that as "consumer invoices" and wrote validation that required a tax ID for everything else. Both halves of that were wrong.&lt;/p&gt;

&lt;p&gt;Wrong on scale: roughly &lt;strong&gt;three in four invoices&lt;/strong&gt; in one real customer's book have no buyer tax ID. Any validation demanding one rejects most of a working invoice book, which is a spectacular way to discover your assumption.&lt;/p&gt;

&lt;p&gt;Wrong on meaning: &lt;code&gt;BrakID&lt;/code&gt; is not consumer-only. A &lt;em&gt;taxpayer&lt;/em&gt; can lack a tax ID too — in Poland, unregistered business activity is the common case — and such an invoice is still fully in scope of the mandate. If you special-case "consumer", you'll misclassify them.&lt;/p&gt;

&lt;p&gt;The subtler bug that came out of this: my validation demanded the ID while my serialiser, twelve lines further down the same file, correctly emitted &lt;code&gt;BrakID&lt;/code&gt;. The two disagreed for months without anyone noticing, because the code path that would have surfaced it was never called in production. Worth grepping for that shape in your own code — a validator and a serialiser that encode the same rule separately will drift, and the drift is silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Session encryption keys are per-session, and generating them is async
&lt;/h2&gt;

&lt;p&gt;Invoices are uploaded encrypted with a symmetric key, which is itself wrapped with the system's public key (RSA-OAEP, SHA-256).&lt;/p&gt;

&lt;p&gt;Two traps here.&lt;/p&gt;

&lt;p&gt;The keys are &lt;strong&gt;per session&lt;/strong&gt;. They are not credentials to cache. Generate them each time you open a session.&lt;/p&gt;

&lt;p&gt;And in the TypeScript client I use, the call that produces them became &lt;code&gt;async&lt;/code&gt; at one point, when it migrated to Web Crypto. My code destructured it as if it were synchronous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// silently produces undefined for every field&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cipherKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cipherIv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encryptionInfo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEncryptionData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// correct&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cipherKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cipherIv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;encryptionInfo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEncryptionData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing throws. You get &lt;code&gt;undefined&lt;/code&gt; for all three, send an empty &lt;code&gt;encryption&lt;/code&gt; field, and the server rejects it several calls later with a code that means "encryption must not be empty". The stack trace points at the session-open call, which is not where the bug is.&lt;/p&gt;

&lt;p&gt;If you're on Deno specifically, one more note: use Web Crypto (&lt;code&gt;crypto.subtle&lt;/code&gt;) rather than the &lt;code&gt;node:crypto&lt;/code&gt; compatibility layer for the RSA-OAEP step. The compat path silently downgraded the mask generation function to SHA-1 for us, which produces ciphertext the server won't accept, and again nothing local fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. When a government API tells you the problem is your data, believe it early
&lt;/h2&gt;

&lt;p&gt;The expensive one.&lt;/p&gt;

&lt;p&gt;Authentication kept failing with &lt;strong&gt;HTTP 450&lt;/strong&gt; and a message saying the token could not be used in the context of our tax ID. 450 is not a standard status code, and the message reads exactly like the sort of thing you get when your request is malformed.&lt;/p&gt;

&lt;p&gt;So I checked everything on my side. Token format, scopes, permissions, the certificate, the encryption, the challenge encoding. All correct. I filed a support ticket. I waited.&lt;/p&gt;

&lt;p&gt;The actual cause: &lt;strong&gt;that tax ID was in a bad state in the test environment's database&lt;/strong&gt;. Nothing I could send would ever have worked. Generating a token for a different, properly registered tax ID authenticated cleanly on the first attempt.&lt;/p&gt;

&lt;p&gt;That cost about three weeks, and the fix would have taken an hour on day one. The ticket was never going to resolve it, because there was nothing to fix on my side and the message had been telling me so the whole time.&lt;/p&gt;

&lt;p&gt;The generalisable lesson: when an integration fails against a government system and the error blames your data, spend an hour testing that hypothesis directly before you spend a week disproving yours. Try a different account. It's cheap and it's decisive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing an integration path at all
&lt;/h2&gt;

&lt;p&gt;Stepping back, there are really three ways to reach KSeF, and the right one depends far more on where invoices are &lt;em&gt;created&lt;/em&gt; than on engineering taste:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct API integration&lt;/strong&gt; — most work up front, least friction afterwards. Right when the system issuing the invoices is yours or your vendor already supports it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File-based&lt;/strong&gt; — the invoicing system stays untouched and something else maps its export into FA(3) and handles the session and receipt lifecycle. Right when the invoices come out of a billing, subscription, or warehouse system that will never speak to KSeF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The government's free web app&lt;/strong&gt; — genuinely fine at low volume, and worth trying before paying anyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the pain above only exists on the first path. That's the honest trade: direct integration means owning sessions, encryption, per-invoice receipts, and error semantics forever.&lt;/p&gt;

&lt;p&gt;I ended up building the middle option as a product, and I wrote up &lt;a href="https://fakturaflow.pl/integracje-ksef" rel="noopener noreferrer"&gt;how the three paths compare, including the exact column mapping a file needs&lt;/a&gt; — that page is in Polish, since that's who has to deal with this, but the structure translates.&lt;/p&gt;

&lt;p&gt;If you're starting a KSeF integration now: read the FA(3) schema before the API docs, assume the receipt is per invoice, and test your assumptions about buyer identifiers against a real invoice book rather than a fixture.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>integration</category>
    </item>
    <item>
      <title>What Google did with a brand new site in its first 24 hours</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Sat, 15 Aug 2026 08:27:21 +0000</pubDate>
      <link>https://dev.to/stackedboost/what-google-did-with-a-brand-new-site-in-its-first-24-hours-knn</link>
      <guid>https://dev.to/stackedboost/what-google-did-with-a-brand-new-site-in-its-first-24-hours-knn</guid>
      <description>&lt;p&gt;We put a new site online with no backlinks, no audience and no history. Sixteen pages, a sitemap, and a Google Search Console property connected the same afternoon. Then we recorded what Google said about every page, the first morning and the next.&lt;/p&gt;

&lt;p&gt;That record is the only reason this article exists. Search Console answers the question for today and keeps nothing, so the day-one state of a site is normally gone by day two. Ours is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened in the first day
&lt;/h2&gt;

&lt;p&gt;On the first afternoon, Google had not heard of any of it. Every page came back as unknown. By the next morning's check, thirteen of the sixteen were indexed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What Google reported&lt;/th&gt;
&lt;th&gt;First afternoon&lt;/th&gt;
&lt;th&gt;Next morning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seen but not indexed yet&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not heard of at all&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the part worth sitting with if you have ever been sold an indexing service. A site with no links pointing at it from anywhere on the internet, published the previous day, was most of the way into Google by the following morning. Nobody paid for that and nothing clever caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit we thought would be the story
&lt;/h2&gt;

&lt;p&gt;Partway through that first afternoon, we opened Search Console and used Request Indexing on nine of the pages. Not as an experiment. We were working through a list and stopped when we got distracted, which left the remaining pages untouched.&lt;/p&gt;

&lt;p&gt;By accident that produced two groups, and the tempting article writes itself: three pages nobody asked about were indexed by morning on the same schedule as the nine we did ask about, so the button does nothing.&lt;/p&gt;

&lt;p&gt;We are not going to tell you that, because the rest of the data does not agree.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Indexed by morning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;We asked Google to index it&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;We did not ask&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nine out of nine against three out of six looks like the button working. It also looks like exactly what you would expect from a list worked through in order by a person who started with the pages he cared about most. The nine were not chosen at random, the groups are tiny, it is one site on one day, and every page was in the same sitemap and linked from the same navigation.&lt;/p&gt;

&lt;p&gt;So the honest answer is that we cannot tell you whether Request Indexing helped, and neither can anyone else running a sample this size. What we can tell you is that three pages got into Google without anybody asking, which is the part most indexing tools would prefer you did not find out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What would settle it.&lt;/strong&gt; Split new pages randomly, ask for half, publish them the same day with the same internal linking, and repeat until the groups are large enough to mean something. We are running that now. It will take months, and any article that claims to have answered this in a week has not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The three pages that did not make it
&lt;/h2&gt;

&lt;p&gt;Two were tool pages we had edited heavily that afternoon, and one was our terms page. By morning Google had discovered the terms page and decided not to index it yet, and still had not heard of the other two.&lt;/p&gt;

&lt;p&gt;This is the ordinary shape of it. Indexing is not a switch that flips for a whole site. It arrives page by page, and the pages that lag are usually the ones with the least reason to exist in Google's eyes: legal boilerplate, and pages that changed after they were first found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the paid version of this does not work
&lt;/h2&gt;

&lt;p&gt;There is a whole product category built on submitting your pages to Google. It is worth knowing what those submissions actually are.&lt;/p&gt;

&lt;p&gt;Google has an Indexing API. Its documentation supports two kinds of content: job postings and live-stream events. Send it a blog post and it returns a success response, because the response tells you the message was received, not that anything will be done with it. We wrote up &lt;a href="https://seomely.com/blog/indexing-api-truth" rel="noopener noreferrer"&gt;what it does with everything else&lt;/a&gt; separately.&lt;/p&gt;

&lt;p&gt;The other half of the bundle is usually IndexNow, which is a genuinely good protocol that notifies Bing, Yandex, Seznam, Naver and Yep. It &lt;a href="https://seomely.com/blog/indexnow-does-not-include-google" rel="noopener noreferrer"&gt;does not include Google&lt;/a&gt; and has never claimed to. We submitted all thirteen of these pages to IndexNow, so if you want a clean reading of the numbers above: the only submission that definitely reached Google was the nine manual ones, and Google indexed pages from both groups regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would actually do with a new site
&lt;/h2&gt;

&lt;p&gt;Nothing exotic. Have a sitemap, link every page from somewhere a person could reach, connect Search Console on day one, and then leave it alone for a week before concluding anything. Most pages arrive on their own.&lt;/p&gt;

&lt;p&gt;The pages that do not arrive are the ones worth your attention, and you will only know which those are if something wrote down what happened. That is the whole argument for keeping a record, and it is the one thing you cannot add later: you cannot go back and observe last week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own
&lt;/h2&gt;

&lt;p&gt;Search Console's URL Inspection tool gives you the same answer we recorded, one page at a time, for free. If you want the state of every page rather than one, the same data is available through its API, which is what we are reading. Either way the answer comes from Google rather than from a tool guessing on its behalf, and that is the only kind of answer worth having.&lt;/p&gt;




&lt;p&gt;Measured on our own property, 14 and 15 August 2026. Sixteen pages, one site, two consecutive mornings. Small enough that you should treat every number above as a description of what happened to us rather than a rule about what will happen to you.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://seomely.com" rel="noopener noreferrer"&gt;Seomely&lt;/a&gt;. It kept this indexing record automatically, and I have been using the same approach on a different question: how a brand actually shows up in ChatGPT and Gemini answers. The first numbers are in a &lt;a href="https://dev.to/stackedboost/we-ran-the-same-chatgpt-prompt-three-times-in-a-row-here-is-what-changed-349k"&gt;follow-up post&lt;/a&gt;. The free &lt;a href="https://seomely.com/check" rel="noopener noreferrer"&gt;index checker&lt;/a&gt; needs no login if you just want to see what is blocking a single page.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>indexing</category>
    </item>
    <item>
      <title>Headless Chrome splits your PDFs in the wrong places. Here is the CSS that fixes it.</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:36:46 +0000</pubDate>
      <link>https://dev.to/stackedboost/headless-chrome-splits-your-pdfs-in-the-wrong-places-here-is-the-css-that-fixes-it-45kg</link>
      <guid>https://dev.to/stackedboost/headless-chrome-splits-your-pdfs-in-the-wrong-places-here-is-the-css-that-fixes-it-45kg</guid>
      <description>&lt;p&gt;If you generate PDFs with headless Chrome, your output probably has a defect you have not looked for.&lt;br&gt;
Mine did. A sixteen-line code block rendered as &lt;code&gt;line_0&lt;/code&gt; through &lt;code&gt;line_9&lt;/code&gt; at the bottom of page one,&lt;br&gt;
then &lt;code&gt;line_10&lt;/code&gt; onwards at the top of page two. Nothing was lost. It just looked like something a&lt;br&gt;
program produced rather than a document a person would send.&lt;/p&gt;

&lt;p&gt;That is Chromium's default fragmentation behaviour. It fills the page, and when it runs out of room&lt;br&gt;
it cuts wherever it happens to be. For a web page that is correct. For a printed document it is the&lt;br&gt;
difference between "generated" and "designed".&lt;/p&gt;

&lt;p&gt;Here is what actually fixes it, what it costs, and one rule that quietly breaks Markdown tables while&lt;br&gt;
looking completely reasonable.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem, concretely
&lt;/h2&gt;

&lt;p&gt;Render a long document with a headless browser and you get four recurring defects:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Blocks cut in half.&lt;/strong&gt; Code blocks, tables and blockquotes split across the page boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stranded headings.&lt;/strong&gt; A heading lands as the last line of a page, its section starting overleaf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orphans and widows.&lt;/strong&gt; A single line of a paragraph alone at the top or bottom of a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table headers that appear once.&lt;/strong&gt; A table spanning three pages labels its columns only on the first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are bugs. They are the absence of instructions.&lt;/p&gt;
&lt;h2&gt;
  
  
  The stylesheet
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Keep blocks whole across page boundaries. */&lt;/span&gt;
&lt;span class="nt"&gt;pre&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;table&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;blockquote&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;figure&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page-break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;tr&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;li&lt;/span&gt;                              &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page-break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Rendered diagrams and display maths are blocks too, and both look absurd cut in half. */&lt;/span&gt;
&lt;span class="nt"&gt;pre&lt;/span&gt;&lt;span class="nc"&gt;.mermaid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.katex-display&lt;/span&gt;         &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page-break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Never strand a heading at the foot of a page. */&lt;/span&gt;
&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h4&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h6&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page-break-after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* At least three lines of a paragraph stay together. */&lt;/span&gt;
&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;orphans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;widows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Repeat the header row when a table continues onto the next page. */&lt;/span&gt;
&lt;span class="nt"&gt;thead&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;table-header-group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Long lines wrap instead of being clipped at the page edge. */&lt;/span&gt;
&lt;span class="nt"&gt;pre&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pre-wrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;anywhere&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Both the modern (&lt;code&gt;break-inside&lt;/code&gt;) and legacy (&lt;code&gt;page-break-inside&lt;/code&gt;) properties are there deliberately.&lt;br&gt;
Chromium understands both, and the older one is still what several rendering paths respect.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;thead { display: table-header-group }&lt;/code&gt; is the one people miss. It is not a hack: it is the CSS&lt;br&gt;
mechanism for repeating headers, and it costs one line.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Keeping blocks whole means pages end early when the next block will not fit. My 17-page test document&lt;br&gt;
became 19 pages, about 12% longer.&lt;/p&gt;

&lt;p&gt;That is the entire trade: slightly more paper, no sliced content. For invoices, reports and receipts&lt;br&gt;
it is obviously worth it. If you are generating something where page count is contractually fixed,&lt;br&gt;
you will want to be more selective about which elements get &lt;code&gt;break-inside: avoid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One limitation worth stating plainly: a block taller than a page will still split, because it has to.&lt;br&gt;
&lt;code&gt;break-inside: avoid&lt;/code&gt; is a preference, not a guarantee. It saves the sixteen-line code block; it&lt;br&gt;
cannot save an eighty-line one.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule that broke my tables
&lt;/h2&gt;

&lt;p&gt;This is the part I would not have found by reading advice. My Markdown pipeline had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#ccc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.45em&lt;/span&gt; &lt;span class="m"&gt;.7em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;left&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable-looking. It also silently destroyed every right-aligned column.&lt;/p&gt;

&lt;p&gt;Markdown lets you align columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Item   | Qty | Price |
|:-------|:---:|------:|
| Widget |  2  |  9.00 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most Markdown parsers implement that with an HTML attribute. Here is what &lt;code&gt;marked&lt;/code&gt; emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;th&lt;/span&gt; &lt;span class="na"&gt;align=&lt;/span&gt;&lt;span class="s"&gt;"right"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Price&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;align=&lt;/span&gt;&lt;span class="s"&gt;"right"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;9.00&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;align&lt;/code&gt; attribute is a &lt;em&gt;presentational hint&lt;/em&gt;, and any CSS rule beats it. So &lt;code&gt;th, td { text-align:&lt;br&gt;
left }&lt;/code&gt; overrode every alignment the author had asked for. Every price column in every invoice was&lt;br&gt;
rendering left-aligned, and nothing in the output looked broken enough to notice.&lt;/p&gt;

&lt;p&gt;The fix is specificity, not removal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;td&lt;/span&gt;                             &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;left&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;align&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"center"&lt;/span&gt;&lt;span class="o"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;td&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;align&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"center"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;th&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;align&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"right"&lt;/span&gt;&lt;span class="o"&gt;],&lt;/span&gt;  &lt;span class="nt"&gt;td&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;align&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"right"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attribute selector (0,1,1) outranks a bare element selector (0,0,1), so the author's alignment&lt;br&gt;
wins again. If your parser emits &lt;code&gt;style="text-align:right"&lt;/code&gt; instead, inline styles already beat your&lt;br&gt;
stylesheet and you never had this bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  Verify by rendering, not by reading
&lt;/h2&gt;

&lt;p&gt;I nearly shipped the page-break fix without checking it, because the CSS is well known and obviously&lt;br&gt;
correct. The reason I know it works is that I rendered a 40-section document before and after and&lt;br&gt;
looked at the pages.&lt;/p&gt;

&lt;p&gt;That is also how I found the table bug, which no amount of reading about page breaks would have&lt;br&gt;
surfaced.&lt;/p&gt;

&lt;p&gt;A cheap way to eyeball output without leaving your terminal: render the PDF, then screenshot it in&lt;br&gt;
the same headless browser you generated it with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--no-sandbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file:///tmp/test.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// let the built-in viewer paint&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/tmp/preview.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome's built-in PDF viewer renders the file, so the screenshot shows real pagination, including the&lt;br&gt;
page boundaries you are trying to fix. Crude, fast, and it catches things a diff never will.&lt;/p&gt;

&lt;p&gt;Worth testing specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A document long enough to paginate, with a code block positioned to straddle a break.&lt;/li&gt;
&lt;li&gt;A table with more rows than fit on one page.&lt;/li&gt;
&lt;li&gt;A table with mixed column alignment.&lt;/li&gt;
&lt;li&gt;Content whose background you did not choose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one deserves a decision rather than a fix. If a caller sends you HTML with&lt;br&gt;
&lt;code&gt;background: #111&lt;/code&gt;, printing a dark page is &lt;em&gt;correct&lt;/em&gt;: they asked for it. Forcing white would be you&lt;br&gt;
overriding an author's design. Only style the paths you own, which for me means the Markdown&lt;br&gt;
renderer, not arbitrary HTML input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Four properties (&lt;code&gt;break-inside&lt;/code&gt;, &lt;code&gt;break-after&lt;/code&gt;, &lt;code&gt;orphans&lt;/code&gt;/&lt;code&gt;widows&lt;/code&gt;, &lt;code&gt;table-header-group&lt;/code&gt;) turn&lt;br&gt;
browser output into document output, at the cost of about 12% more pages. Then check whether your own&lt;br&gt;
stylesheet is overriding your parser's alignment, because that failure is invisible until someone&lt;br&gt;
looks at a price column and frowns.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The stylesheet above is the one I ship in my own&lt;br&gt;
&lt;a href="https://mintpdf.dev/markdown-to-pdf" rel="noopener noreferrer"&gt;Markdown to PDF converter&lt;/a&gt;, which is where all of this came&lt;br&gt;
from. It is free and needs no account, so it is an easy way to see the difference these rules make&lt;br&gt;
without wiring up Puppeteer yourself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>css</category>
      <category>puppeteer</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Splitting Polish statutory interest across central bank rate changes</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Tue, 28 Jul 2026 16:23:45 +0000</pubDate>
      <link>https://dev.to/stackedboost/splitting-polish-statutory-interest-across-central-bank-rate-changes-4bg3</link>
      <guid>https://dev.to/stackedboost/splitting-polish-statutory-interest-across-central-bank-rate-changes-4bg3</guid>
      <description>&lt;p&gt;If you invoice a business in Poland and they pay late, you are entitled to statutory interest. Computing it looks like a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interest = amount × rate × days / 365
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is wrong often enough to matter, for a reason that has nothing to do with rounding: &lt;strong&gt;the rate changes while the debt is outstanding&lt;/strong&gt;, and you have to split the delay into segments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two regimes, two different clocks
&lt;/h2&gt;

&lt;p&gt;Poland has two separate statutory interest rates, and mixing them up is the single most common error I see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Odsetki ustawowe za opóźnienie&lt;/strong&gt; (Civil Code) apply to ordinary obligations. The rate is defined relative to the National Bank of Poland reference rate, so &lt;strong&gt;it changes whenever the central bank moves rates&lt;/strong&gt;, effective from the date of the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Odsetki za opóźnienie w transakcjach handlowych&lt;/strong&gt; apply to business-to-business commercial transactions. This rate is fixed &lt;strong&gt;per half-year&lt;/strong&gt;: it is set from the NBP reference rate on 1 January for the first half, and on 1 July for the second, and it does not move in between even if the central bank cuts rates in March.&lt;/p&gt;

&lt;p&gt;So for the same 200-day delay, one regime may need five segments and the other exactly two. Picking the wrong regime does not just change a number, it changes the shape of the calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The segmentation
&lt;/h2&gt;

&lt;p&gt;Once you accept that the rate is a function of time rather than a constant, the calculation becomes a fold over intervals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RatePeriod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ratePercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;interestForPeriods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;amountGrosze&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;periods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RatePeriod&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;periods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;daysBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// integer grosze throughout; never accumulate in floats&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;amountGrosze&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ratePercent&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;365&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in that small function are load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Money stays in integer grosze.&lt;/strong&gt; &lt;code&gt;0.1 + 0.2 !== 0.3&lt;/code&gt;, and interest is a sum of many small terms. Accumulating in floats produces drift that shows up as a one-grosz disagreement with the other side's accountant, which is exactly the kind of thing that turns a payment discussion into an argument.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rounding happens per segment, not once at the end.&lt;/strong&gt; This is a modelling decision rather than a mathematical one: each segment is a distinct legally-defined period, so it is the natural unit to round. Whichever convention you choose, choose it deliberately and document it, because the two approaches differ by a grosz or two and someone will eventually ask why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The day count is exclusive of one endpoint.&lt;/strong&gt; Off-by-one over a 400-day delay is not a rounding error, it is a whole day of interest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the period list
&lt;/h2&gt;

&lt;p&gt;The segmentation itself is a merge between the delay window and the rate timeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;splitByRateChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rateTable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ratePercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RatePeriod&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;rateTable&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveFrom&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RatePeriod&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;effectiveFrom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;effectiveFrom&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// clip the rate window to the delay window&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segFrom&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;segTo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;segFrom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;segTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ratePercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;ratePercent&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the commercial-transactions regime you feed it a rate table with two entries per year. For the Civil Code regime you feed it every NBP decision. Same fold, different input, which is the point: the regime difference belongs in the data, not in the control flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use the current rate
&lt;/h2&gt;

&lt;p&gt;Because the answer is wrong, and wrong in a direction that is easy to spot. During a rate-cutting cycle, applying today's rate to the whole delay understates what you are owed. During a hiking cycle it overstates it, which is worse, because now your demand letter contains a number the debtor can dispute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth knowing if you build this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rate tables need a source of truth with effective dates, and they change with no notice on your schedule.&lt;/li&gt;
&lt;li&gt;Store the segment breakdown, not just the total. When someone challenges the figure, "here are the five periods and the rate in each" ends the conversation; a single number does not.&lt;/li&gt;
&lt;li&gt;Test around the boundaries: a delay that starts on the day of a rate change, one that ends on it, and one entirely inside a single period.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;I maintain &lt;a href="https://wezwaniepro.pl" rel="noopener noreferrer"&gt;WezwaniePro&lt;/a&gt;, a free generator for Polish payment demands and interest notes that does this segmentation automatically. The arithmetic above is the part that took longest to get right, which is why it seemed worth writing down.&lt;/p&gt;

&lt;p&gt;If you have implemented statutory interest for another jurisdiction and handled the rate-change problem differently, I would be interested to hear it.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>algorithms</category>
      <category>fintech</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Poland's e-invoicing system has no JavaScript SDK, so I published the validation layer</title>
      <dc:creator>Peter Hallander</dc:creator>
      <pubDate>Mon, 27 Jul 2026 18:39:15 +0000</pubDate>
      <link>https://dev.to/stackedboost/polands-e-invoicing-system-has-no-javascript-sdk-so-i-published-the-validation-layer-j6m</link>
      <guid>https://dev.to/stackedboost/polands-e-invoicing-system-has-no-javascript-sdk-so-i-published-the-validation-layer-j6m</guid>
      <description>&lt;p&gt;Poland runs a national e-invoicing system called &lt;strong&gt;KSeF&lt;/strong&gt; (Krajowy System e-Faktur). Business-to-business invoices are submitted to a government API in a schema called &lt;strong&gt;FA(3)&lt;/strong&gt;, and the system hands back an official confirmation of receipt. If you sell software to Polish companies, you will meet it.&lt;/p&gt;

&lt;p&gt;The Ministry of Finance publishes official SDKs for &lt;strong&gt;Java and .NET&lt;/strong&gt;. There is nothing for JavaScript.&lt;/p&gt;

&lt;p&gt;A full client is a real project: authentication, session handling, certificates, XML signing. But a large share of rejected invoices have nothing to do with any of that. They are structural. A tax ID with a bad checksum. Net plus VAT that does not add up to gross. A date that does not exist. Those are worth catching on your side, before you build a session with anyone.&lt;/p&gt;

&lt;p&gt;So I pulled that layer out of a product I work on, rewrote it standalone, and published it: &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/ksef-invoice-validate" rel="noopener noreferrer"&gt;ksef-invoice-validate&lt;/a&gt;&lt;/strong&gt;. Zero dependencies, no network calls, runs in the browser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i ksef-invoice-validate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateInvoiceForKsef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ksef-invoice-validate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateInvoiceForKsef&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;invoice_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FV/2026/07/1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;issue_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-07-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;seller_nip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1111111111&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buyer_nip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1111111111&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount_net&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount_vat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;230&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount_gross&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1230&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in it were more interesting than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NIP checksum
&lt;/h2&gt;

&lt;p&gt;A Polish tax identification number (NIP) is ten digits. The tenth is a checksum over the first nine, each weighted and reduced modulo 11.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checksum&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// invalid&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a small elegance here. The remainder can be 10, and no single digit equals 10, so those numbers simply cannot exist as valid NIPs. You do not need a special case. The comparison rejects them on its own.&lt;/p&gt;

&lt;p&gt;This alone catches a surprising amount. Most bad tax IDs in the wild are transposed digits from manual entry, and a mod-11 check catches every single-digit transposition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never compare money as floats
&lt;/h2&gt;

&lt;p&gt;The original implementation checked that net plus VAT equals gross with a tolerance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;net&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;vat&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* mismatch */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the bug everyone writes once. &lt;code&gt;0.1 + 0.2 === 0.30000000000000004&lt;/code&gt;, and on invoice arithmetic the drift lands exactly where you cannot afford it: a comparison against a rounding tolerance. Values that should pass fail, and it is not reproducible in a way that looks like a pattern.&lt;/p&gt;

&lt;p&gt;The fix is to leave floating point entirely and compare in integer &lt;strong&gt;grosze&lt;/strong&gt; (Polish cents):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grosze&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;grosze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;grosze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;grosze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* mismatch */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tolerance is now literally "one grosz", which is a statement about money rather than about IEEE 754.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dates that do not exist
&lt;/h2&gt;

&lt;p&gt;This one is quiet and nasty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-02-31&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no error. Rolls over to March 3rd.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; will happily accept the 31st of February and silently move you into March. On an invoice, an issue date that shifts by three days is not a cosmetic problem.&lt;/p&gt;

&lt;p&gt;The cheapest reliable check is a round trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// not a real calendar date&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the string you get back is not the string you put in, &lt;code&gt;Date&lt;/code&gt; corrected you, which means the input was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Codes, not sentences
&lt;/h2&gt;

&lt;p&gt;The internal version returned i18n message keys, which are fine inside one app and useless in a library. Every error now carries a stable code you can switch on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nip.format | nip.checksum | date.format | date.invalid
date.future | amount.negative | amount.mismatch | field.required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The English message is there for humans reading logs. The code is the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not do
&lt;/h2&gt;

&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; a substitute for validating against the official FA(3) XSD, and it cannot check anything that requires the Ministry's systems: duplicate detection, counterparty status, authorisation, session handling. Think of it as the cheap check you run first, so that the expensive one has less to complain about.&lt;/p&gt;

&lt;p&gt;It also does not know your business rules. Buyer tax ID is required by default because the common case is B2B, but consumers and foreign buyers legitimately have no Polish NIP, so that is a flag rather than a law.&lt;/p&gt;




&lt;p&gt;Package: &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/ksef-invoice-validate" rel="noopener noreferrer"&gt;ksef-invoice-validate&lt;/a&gt;&lt;/strong&gt; on npm.&lt;br&gt;
Source: &lt;strong&gt;&lt;a href="https://github.com/TrendTweekers/ksef-invoice-validate" rel="noopener noreferrer"&gt;github.com/TrendTweekers/ksef-invoice-validate&lt;/a&gt;&lt;/strong&gt;, MIT licensed.&lt;/p&gt;

&lt;p&gt;It was extracted from the validation layer of &lt;a href="https://fakturaflow.pl" rel="noopener noreferrer"&gt;FakturaFlow&lt;/a&gt;, a KSeF tool for Polish accounting offices. The checks themselves are general enough to be useful well outside it, which is why they are now their own package.&lt;/p&gt;

&lt;p&gt;If you have implemented FA(3) in TypeScript and solved parts of this differently, I would genuinely like to hear it. The JS side of KSeF is thin enough that most of us are solving the same problems alone.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>node</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
