<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Free BizDoc Dev Blog]]></title><description><![CDATA[Build notes from Free BizDoc — a free business-document generator. Edge infra, client-side PDFs, and solo-SaaS lessons.]]></description><link>https://freebizdoc.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:14:24 GMT</lastBuildDate><atom:link href="https://freebizdoc.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[One invoice form, four tax systems: US sales tax, UK VAT, AU GST, CA GST/HST/PST]]></title><description><![CDATA[I run a free invoice generator. When I expanded it from Japan to the English-speaking market, I assumed "add other currencies" was the hard part. It…]]></description><link>https://freebizdoc.hashnode.dev/one-invoice-form-four-tax-systems-us-sales-tax-uk-vat-au-gst-ca-gst-hst-pst</link><guid isPermaLink="true">https://freebizdoc.hashnode.dev/one-invoice-form-four-tax-systems-us-sales-tax-uk-vat-au-gst-ca-gst-hst-pst</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[taxes]]></category><dc:creator><![CDATA[Free BizDoc]]></dc:creator><pubDate>Wed, 04 Nov 2026 07:41:21 GMT</pubDate><content:encoded><![CDATA[<p>I run a free invoice generator. When I expanded it from Japan to the English-speaking market, I assumed "add other currencies" was the hard part. It wasn't. The hard part was tax — because "add 10%" is not how tax works once you cross a border.</p>
<p>A US invoice, a UK invoice, an Australian tax invoice and a Canadian invoice are <strong>four genuinely different documents</strong>, not one document with a different percentage. Here's what I had to model to make a single form produce all four correctly.</p>
<h2>The four systems, briefly</h2>
<ul>
<li><strong>US — sales tax.</strong> No national rate. It's state (and often county/city) level, and <em>the seller only charges it where they have nexus</em>. On a simple invoice tool, the realistic move is: let the user enter their own rate, show it as "Sales Tax," and never pretend there's one number.</li>
<li><strong>UK — VAT.</strong> One national rate (20% standard), but the invoice must show the <strong>VAT registration number</strong> and the VAT amount as a line, and "VAT invoice" is a specific legal document. Not every small business is VAT-registered, so VAT has to be <em>optional</em>.</li>
<li><strong>Australia — GST.</strong> 10% flat, but the document itself changes name: once GST is included it's a <strong>"Tax Invoice,"</strong> and that label is required wording. Below AUD 1,000 vs above has different ID requirements. The ABN (not a VAT number) goes on it.</li>
<li><strong>Canada — GST/HST + PST.</strong> This is the spicy one. Depending on the province you get <strong>GST alone, HST (a combined federal+provincial rate), or GST + a separate PST/QST</strong>. Ontario is 13% HST; Alberta is 5% GST only; Quebec is 5% GST <strong>plus</strong> 9.975% QST calculated separately. So Canada isn't one rate — it's a per-province rule set.</li>
</ul>
<h2>What that means for the data model</h2>
<p>The naive model is <code>taxRate: number</code>. That dies immediately on Canada. What actually works is a small per-region descriptor:</p>
<pre><code class="language-js">const TAX = {
  US: { label: 'Sales Tax', mode: 'single', rateEditable: true, idLabel: null },
  UK: { label: 'VAT', mode: 'single', rate: 20, idLabel: 'VAT No.', optional: true },
  AU: { label: 'GST', mode: 'single', rate: 10, idLabel: 'ABN', docName: 'Tax Invoice' },
  CA: { label: 'GST/HST', mode: 'province', idLabel: 'GST/HST No.' }, // per-province
};
// Canada resolves to one or two components:
const CA_PROVINCES = {
  ON: [{ label: 'HST', rate: 13 }],
  AB: [{ label: 'GST', rate: 5 }],
  BC: [{ label: 'GST', rate: 5 }, { label: 'PST', rate: 7 }],
  QC: [{ label: 'GST', rate: 5 }, { label: 'QST', rate: 9.975 }],
  // ...
};
</code></pre>
<p>Now tax is a <strong>list of components</strong>, not a scalar. The US/UK/AU cases are just a list of length one. Canada is length one or two. The renderer doesn't care how many there are:</p>
<pre><code class="language-js">function taxLines(region, province, subtotal) {
  const comps = region === 'CA'
    ? CA_PROVINCES[province]
    : [{ label: TAX[region].label, rate: resolveRate(region) }];
  return comps.map(c =&gt; ({
    label: `${c.label} (${c.rate}%)`,
    amount: round(subtotal * c.rate / 100),
  }));
}
</code></pre>
<p>Once tax is a list, every "weird" case collapses into the same loop. That one refactor — scalar to list of components — is the whole trick.</p>
<h2>The non-obvious bits that bit me</h2>
<ul>
<li><strong>The document name is data, too.</strong> Australia's "Tax Invoice" wording isn't decoration; switching region has to switch the H1/title, not just the math. I ended up treating the doc label as part of the region descriptor.</li>
<li><strong>Rounding is per-component, per-line — and countries disagree.</strong> Summing then rounding vs rounding then summing can differ by a cent, and some tax authorities are specific about it. Pick a rule, apply it consistently, and round each tax component independently (especially for Canada's two-component provinces).</li>
<li><strong>Tax ID labels differ and some are optional.</strong> VAT No., ABN, GST/HST No. — same slot, different label, and for a not-yet-registered freelancer the whole field should disappear rather than print an empty "VAT No.:".</li>
<li><strong>Currency is not region.</strong> Someone in the US invoicing in EUR is a real case. I keep currency and tax region as independent selectors; coupling them was an early mistake.</li>
</ul>
<h2>Why bother, for a free tool</h2>
<p>Because "close enough" tax is worse than no tax — it makes the document look official while being subtly wrong, which is exactly what a small business can't afford. Modelling it properly turned out to be maybe 150 lines of config, not a tax engine. The leverage was in the <em>shape</em> of the data (components, not a rate), not in the amount of code.</p>
<p>If you want to see all four in action, the generator is here: <strong><a href="https://free-bizdocs.com/en/invoice">free-bizdocs.com/en/invoice</a></strong> — switch the region selector and watch the labels, the document name and the tax breakdown change together.</p>
<p>I'm sure I'm still missing edge cases (provincial point-of-sale rebates, EU reverse-charge, US origin-vs-destination sourcing). If you've shipped multi-jurisdiction tax, I'd genuinely like to hear what broke for you.</p>
]]></content:encoded></item><item><title><![CDATA[Sharing an invoice without storing it anywhere: a URL that IS the document]]></title><description><![CDATA[I run a small, free invoice generator. One feature request kept coming back: "Can I send the invoice as a link instead of a PDF attachment?" The obvious…]]></description><link>https://freebizdoc.hashnode.dev/sharing-an-invoice-without-storing-it-anywhere-a-url-that-is-the-document</link><guid isPermaLink="true">https://freebizdoc.hashnode.dev/sharing-an-invoice-without-storing-it-anywhere-a-url-that-is-the-document</guid><category><![CDATA[cloudflare]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Free BizDoc]]></dc:creator><pubDate>Wed, 04 Nov 2026 04:41:56 GMT</pubDate><content:encoded><![CDATA[<p>I run a small, free invoice generator. One feature request kept coming back: <em>"Can I send the invoice as a link instead of a PDF attachment?"</em></p>
<p>The obvious way is to save the document to a database, mint an ID, and serve <code>/invoice/abc123</code>. But I didn't want to do that. A free tool that quietly stores everyone's invoices — names, amounts, bank details — is a liability I'd rather not own. Storage costs money, backups are a responsibility, and "we keep a copy of your billing data" is the opposite of what a throwaway tool should promise.</p>
<p>So I built sharing with <strong>zero server storage</strong>. The link <em>contains</em> the document. Open it and your browser reconstructs the invoice locally. Nothing ever hits my database.</p>
<p>Here's how it works, and the three gotchas that took the longest to get right.</p>
<h2>The core idea: the fragment is the payload</h2>
<p>A URL has a part the browser never sends to the server — everything after the <code>#</code>. That's the fragment (hash). I encode the whole invoice into it:</p>
<pre><code class="language-js">// Create a share link
function makeShareLink(doc) {
  const json = JSON.stringify(doc);              // {items, client, company, total, dueDate, ...}
  const packed = btoa(encodeURIComponent(json)); // UTF-8 safe base64
  return `${location.origin}/invoice#s=${packed}`;
}
</code></pre>
<p>On the receiving side, the page reads its own hash on load and rebuilds the document — read-only, with the amount, bank details and due date surfaced at the top:</p>
<pre><code class="language-js">// On page load
const m = location.hash.match(/[#&amp;]s=([^&amp;]+)/);
if (m) {
  const doc = JSON.parse(decodeURIComponent(atob(m[1])));
  renderReadOnly(doc);   // no edit UI, just the invoice + a "pay to" panel
}
</code></pre>
<p>Because the data lives in the fragment, <strong>the server literally never receives it.</strong> My Cloudflare Function logs show a request for <code>/invoice</code> with no query string — the <code>#s=...</code> part stays in the browser. No row is written. No ID exists to leak.</p>
<h2>Gotcha 1: btoa chokes on non-ASCII</h2>
<p><code>btoa("請求書")</code> throws <code>InvalidCharacterError</code>. Base64 in the browser only speaks Latin-1, and my users write invoices in Japanese, German, Spanish… The fix is to percent-encode to UTF-8 first (<code>encodeURIComponent</code>) and decode after (<code>decodeURIComponent</code>). Miss it and every non-English invoice silently breaks.</p>
<h2>Gotcha 2: the PDF is generated on the client too</h2>
<p>If the server never sees the data, it obviously can't render the PDF. I generate it in the browser with <a href="https://pdf-lib.js.org/">pdf-lib</a> and embed a font so Japanese doesn't turn into tofu:</p>
<pre><code class="language-js">import { PDFDocument } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';

const pdf = await PDFDocument.create();
pdf.registerFontkit(fontkit);
const font = await pdf.embedFont(await fetch('/fonts/noto-jp.ttf').then(r =&gt; r.arrayBuffer()));
// ...draw the invoice with `font`, then pdf.save()
</code></pre>
<p>Keep the font same-origin. I initially loaded it from a CDN and spent an afternoon on CORS/<code>ArrayBuffer</code> failures that only showed up on some networks.</p>
<h2>Gotcha 3: links get long — decide what not to include</h2>
<p>A full invoice with ten line items and addresses is a few KB of JSON → a chunky base64 string. Browsers and chat apps tolerate surprisingly long URLs (tens of KB), but I still draw the line:</p>
<ul>
<li><strong>In the link:</strong> line items, client, company, totals, due date, bank details.</li>
<li><strong>Never in the link:</strong> uploaded logos and stamp images. A base64 PNG would blow the URL up to hundreds of KB. The shared view just omits them.</li>
</ul>
<p>That one rule kept links pasteable in email and messaging apps without truncation.</p>
<h2>Why this shape is nice beyond privacy</h2>
<ul>
<li><strong>No backend for the feature.</strong> The entire thing is static assets plus a tiny Cloudflare Function that only serves the shell. On Cloudflare Pages the whole site runs at effectively zero fixed cost.</li>
<li><strong>Nothing to expire or clean up.</strong> There's no orphaned-rows table growing forever. Delete the link, delete the data.</li>
<li><strong>Trivially offline-friendly.</strong> Rebuilding the doc is pure client code.</li>
</ul>
<p>The trade-off is honest: the data is only as private as the link. Anyone with the URL can read it — same as a PDF attachment — and you can't revoke it after sending. For a <em>shareable invoice</em> that's exactly the right model; for anything you need to lock down later, store it server-side and gate it behind auth.</p>
<p>If you want to poke at the running version, the invoice tool is here: <strong><a href="https://free-bizdocs.com/en/invoice">free-bizdocs.com/en/invoice</a></strong> — create one, hit "share link", and watch the network tab stay quiet.</p>
<p>Would I use the fragment-as-database trick everywhere? No. But for "let the user hand someone a self-contained document, and don't you dare keep a copy," it's the simplest thing that works.</p>
]]></content:encoded></item></channel></rss>