<?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: jiahao luo</title>
    <description>The latest articles on DEV Community by jiahao luo (@jiahao_luo_f33d8988caf4e1).</description>
    <link>https://dev.to/jiahao_luo_f33d8988caf4e1</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3876649%2Fdce1e942-9dfc-4d36-909b-6a8a04b54f81.png</url>
      <title>DEV Community: jiahao luo</title>
      <link>https://dev.to/jiahao_luo_f33d8988caf4e1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jiahao_luo_f33d8988caf4e1"/>
    <language>en</language>
    <item>
      <title>Every Invoice Tool Wants My Data. I Built One That Doesn't.</title>
      <dc:creator>jiahao luo</dc:creator>
      <pubDate>Mon, 13 Apr 2026 12:28:13 +0000</pubDate>
      <link>https://dev.to/jiahao_luo_f33d8988caf4e1/every-invoice-tool-wants-my-data-i-built-one-that-doesnt-4b1m</link>
      <guid>https://dev.to/jiahao_luo_f33d8988caf4e1/every-invoice-tool-wants-my-data-i-built-one-that-doesnt-4b1m</guid>
      <description>&lt;p&gt;My girlfriend asked me to help her make an invoice. "You're good with computers."&lt;/p&gt;

&lt;p&gt;I googled "free invoice generator." Here's what happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First result: signup required&lt;/li&gt;
&lt;li&gt;Second: free trial expired, credit card&lt;/li&gt;
&lt;li&gt;Third: works but PDF has a watermark across the whole thing&lt;/li&gt;
&lt;li&gt;Fourth: actually decent but generates the PDF on their server — meaning they see my girlfriend's client name, her bank details, everything&lt;/li&gt;
&lt;li&gt;Fifth: Google Docs template from Pinterest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm a frontend developer. Seven years. I sat there thinking: this is a form with like 10 fields, a table that does multiplication, and a PDF export. Why does any of this need a backend? Why does it need my email? Why is the PDF being generated on someone else's server?&lt;/p&gt;

&lt;p&gt;It doesn't. So I built one where it doesn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ainvoicemaker.com" rel="noopener noreferrer"&gt;ainvoicemaker.com&lt;/a&gt;. Next.js. Client-side PDF. Zero server storage. Your invoice data never leaves your browser. Open, fill, download.&lt;/p&gt;

&lt;p&gt;Here's everything that went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  jsPDF pretends Unicode doesn't exist
&lt;/h2&gt;

&lt;p&gt;jsPDF ships with three fonts. Helvetica, Times, Courier. End of list.&lt;/p&gt;

&lt;p&gt;User types Chinese → blank rectangles. Japanese → blank rectangles.&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;hasCJK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;4e00-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;9fff&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;3400-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;4dbf&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;3040-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;30ff&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;ac00-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;d7af&lt;/span&gt;&lt;span class="se"&gt;]&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;text&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;Detect CJK → dynamically fetch NotoSansSC from Google Fonts. 10MB. Fine on fiber. Not fine on a phone in Jakarta.&lt;/p&gt;

&lt;p&gt;AbortController, 8-second timeout. Font doesn't load? Helvetica it is. The CJK will be garbled but at least the tab doesn't freeze. I'll take "wrong" over "frozen" every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image.naturalWidth returns 0 and I wasted two hours
&lt;/h2&gt;

&lt;p&gt;Logo rendering. Need aspect ratio. Did the obvious:&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;img&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;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Always zero. Because &lt;code&gt;.src&lt;/code&gt; assignment is async-ish and the browser hasn't decoded anything yet. I knew this conceptually. But when you're debugging at 11pm and the math looks right and the image is definitely there, you don't think "maybe the browser hasn't started loading this yet." You think "my ratio calculation is wrong" and you spend two hours refactoring perfectly correct math.&lt;/p&gt;

&lt;p&gt;Fix:&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;props&lt;/span&gt; &lt;span class="o"&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;getImageProperties&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="nx"&gt;logoDataUrl&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;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;jsPDF has a built-in method that actually works. Two hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intl.NumberFormat does everything and nobody talks about it
&lt;/h2&gt;

&lt;p&gt;55 currencies. Was building a lookup table. Got to currency #8, thought "I refuse to do this manually."&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="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ja-JP&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;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&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;JPY&lt;/span&gt;&lt;span class="dl"&gt;"&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="mf"&gt;1234.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// "￥1,235"  — knows JPY has zero decimals&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;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&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;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1234.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// "1.234,50 €"  — knows Germany uses comma decimals and puts € after&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser knows all of this. Has since 2016. I deleted my lookup table. Genuinely mad that I've seen a hundred "how to format currency in JavaScript" tutorials and none of them mention this API.&lt;/p&gt;

&lt;h2&gt;
  
  
  96% of my users were Playwright
&lt;/h2&gt;

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

&lt;p&gt;Launched the app. Analytics: 175 sessions, 37 PDF downloads. Nice. Spent two days building dashboards, analyzing funnels.&lt;/p&gt;

&lt;p&gt;Then I filtered the data. Removed HeadlessChrome (Playwright E2E — 226 tests, each firing analytics events). My own dev IPs. Cloud scrapers.&lt;/p&gt;

&lt;p&gt;Real external humans: ~45. Real PDF downloads from non-developers: 1.&lt;/p&gt;

&lt;p&gt;I'd been making product decisions based on my own test suite. If you run E2E tests that trigger your analytics endpoint, filter at collection time. Not at query time. I now drop all HeadlessChrome requests before they hit the database.&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;shouldDrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&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;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HeadlessChrome&lt;/span&gt;&lt;span class="dl"&gt;"&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;true&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;false&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;Should've been line 1 of the analytics API. Wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  localStorage: why I can't have nice things
&lt;/h2&gt;

&lt;p&gt;No signup = no database. Returning users shouldn't re-enter their business name.&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;KEYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;BUSINESS_PROFILE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_business&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;RECENT_CLIENTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_clients&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;INVOICE_SEQUENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_sequence&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;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing that works well: auto-incrementing invoice numbers (&lt;code&gt;INV-0001&lt;/code&gt;, &lt;code&gt;INV-0002&lt;/code&gt;...) that persist across sessions. Come back next month and it picks up where you left off. Feels like the tool remembers you. It doesn't. It's 47 bytes in localStorage. But it feels nice.&lt;/p&gt;

&lt;p&gt;The problem nobody warns you about: user clears browser data, everything's gone. Switches to phone, nothing syncs. The real fix is "optional account" but then I'm adding signups, which is the thing I built this entire tool to avoid.&lt;/p&gt;

&lt;p&gt;Still don't have a good answer for this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dumb mistake
&lt;/h2&gt;

&lt;p&gt;Code works fine. PDF generates. Tool does what it should.&lt;/p&gt;

&lt;p&gt;My mistake was naming it "AI Invoice Generator" because AI sounds trendy. Nobody searches "AI invoice" — like 500 queries a month. "Invoice generator" gets 49,500. I optimized for vibes instead of for what people actually type into Google. Main keyword ranks position 72. That's page 8. Nobody scrolls to page 8.&lt;/p&gt;

&lt;p&gt;Also built 7 document types, 55 currencies, 9 languages, and an auto-reminder system before having more than 2 visitors a day. Developer brain. Building is fun. Marketing is not. So you build.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ainvoicemaker.com" rel="noopener noreferrer"&gt;ainvoicemaker.com&lt;/a&gt; — everything stays in your browser. No signup, no server storage. I just need humans to find it now.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>privacy</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
