<?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: Brandon Sooknanan</title>
    <description>The latest articles on DEV Community by Brandon Sooknanan (@paidandfiled).</description>
    <link>https://dev.to/paidandfiled</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%2F4245261%2Fd2233e14-b5e8-491a-8b67-4ad1abe5de5e.png</url>
      <title>DEV Community: Brandon Sooknanan</title>
      <link>https://dev.to/paidandfiled</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paidandfiled"/>
    <language>en</language>
    <item>
      <title>Going from W-2 to 1099 as a developer: the tax math nobody puts in the offer</title>
      <dc:creator>Brandon Sooknanan</dc:creator>
      <pubDate>Sun, 15 Nov 2026 01:55:45 +0000</pubDate>
      <link>https://dev.to/paidandfiled/going-from-w-2-to-1099-as-a-developer-the-tax-math-nobody-puts-in-the-offer-13li</link>
      <guid>https://dev.to/paidandfiled/going-from-w-2-to-1099-as-a-developer-the-tax-math-nobody-puts-in-the-offer-13li</guid>
      <description>&lt;p&gt;A lot of developers get their first contract offer the same way: a recruiter says "$90 an hour on a 1099" and it sounds like a raise over a $150,000 salary. Sometimes it is. Often it isn't, and the difference is mostly tax and benefits that nobody spells out.&lt;/p&gt;

&lt;p&gt;This is the short version of a longer guide I wrote, &lt;a href="https://paidandfiled.com/guides/1099-vs-w2/" rel="noopener noreferrer"&gt;1099 vs W-2: how the taxes really differ&lt;/a&gt;, with US federal numbers for 2026.&lt;/p&gt;

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

&lt;p&gt;The income tax brackets are identical for W-2 and 1099 income. Three things change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who pays the payroll tax.&lt;/strong&gt; As an employee you pay 7.65% for Social Security and Medicare and your employer quietly pays another 7.65%. As a contractor you pay both halves: &lt;strong&gt;15.3%&lt;/strong&gt; self-employment tax, on 92.35% of your profit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you pay.&lt;/strong&gt; Nobody withholds anything from a contractor. The IRS expects &lt;a href="https://paidandfiled.com/guides/quarterly-estimated-tax-due-dates/" rel="noopener noreferrer"&gt;quarterly estimated payments&lt;/a&gt; in April, June, September and January, and charges an underpayment penalty if you wait until April to pay it all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you can deduct.&lt;/strong&gt; A contractor deducts real business costs before tax: the laptop, a monitor, software subscriptions, part of a phone bill, a home office. An employee can't deduct unreimbursed work expenses on a federal return.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The payroll tax gap, in dollars
&lt;/h2&gt;

&lt;p&gt;On $150,000 of W-2 salary you pay about &lt;strong&gt;$11,475&lt;/strong&gt; in Social Security and Medicare tax.&lt;/p&gt;

&lt;p&gt;On $150,000 of contract profit you pay about &lt;strong&gt;$21,194&lt;/strong&gt; in self-employment tax. You get half of that back as a deduction from income, which saves a few thousand dollars of income tax, but you're still well behind.&lt;/p&gt;

&lt;p&gt;So before thinking about anything else, a contract rate needs to be roughly &lt;strong&gt;7.65% higher&lt;/strong&gt; than the salary just to cover the employer's half of payroll tax. Then add what the salaried job included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;health insurance (often $500 to $1,500 a month on your own),&lt;/li&gt;
&lt;li&gt;a 401(k) match,&lt;/li&gt;
&lt;li&gt;paid holidays, vacation and sick days (contract hours you don't work, you don't bill),&lt;/li&gt;
&lt;li&gt;equipment and software the company paid for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why the common rule of thumb is &lt;strong&gt;25% to 40% more&lt;/strong&gt; than the salary, and why $90/hour can come out about even with, or below, a $150,000 salary once you count roughly 1,800 billable hours a year instead of 2,080 and pay for your own benefits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What helps on the 1099 side
&lt;/h2&gt;

&lt;p&gt;It isn't all downside. Contractors get some things employees don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The QBI deduction.&lt;/strong&gt; Most sole proprietors can deduct up to 20% of their business profit from taxable income. At typical developer incomes it's worth thousands. (&lt;a href="https://paidandfiled.com/guides/qbi-deduction/" rel="noopener noreferrer"&gt;How it works&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger retirement contributions.&lt;/strong&gt; A solo 401(k) or SEP IRA lets you shelter far more than most employer plans would, and it's deductible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health insurance premiums are deductible&lt;/strong&gt; on Schedule 1 if you're not eligible for an employer plan (including a spouse's). (&lt;a href="https://paidandfiled.com/guides/self-employed-health-insurance-deduction/" rel="noopener noreferrer"&gt;Details&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business expenses.&lt;/strong&gt; That new laptop is a deduction, not a cost you eat.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The habit that makes it work
&lt;/h2&gt;

&lt;p&gt;The single biggest mistake new contractors make is spending the whole invoice. A simple fix: every time a client pays you, move a fixed percentage into a separate savings account for tax. For most freelance developers that's somewhere around 25% to 35%, depending on income and state. There's a table by income in &lt;a href="https://paidandfiled.com/guides/how-much-to-set-aside-for-taxes/" rel="noopener noreferrer"&gt;how much to set aside for taxes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather compute it than guess, there's a free &lt;a href="https://paidandfiled.com/quarterly-tax-calculator/" rel="noopener noreferrer"&gt;quarterly tax calculator&lt;/a&gt; that runs entirely in the browser: enter your expected profit and it shows the self-employment tax, income tax and what each quarterly payment should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are you really a contractor?
&lt;/h2&gt;

&lt;p&gt;One more thing worth knowing: the label on your contract doesn't decide it. The IRS looks at behavioral control (do they tell you how and when to work?), financial control (whose equipment, can you take other clients?) and the relationship (benefits, an indefinite engagement). A "contractor" who works fixed hours on the company's laptop for one client indefinitely may legally be an employee. If you think you've been misclassified, Form SS-8 asks the IRS to decide, and Form 8919 lets you pay only the employee's share of payroll tax.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://paidandfiled.com/guides/1099-vs-w2/" rel="noopener noreferrer"&gt;full guide&lt;/a&gt; has the side-by-side table and the sources. This is general information, not tax advice, and state taxes are on top of all of it.&lt;/p&gt;

</description>
      <category>career</category>
      <category>freelance</category>
      <category>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Making a calculator other sites can embed, on a fully static site</title>
      <dc:creator>Brandon Sooknanan</dc:creator>
      <pubDate>Sun, 15 Nov 2026 01:49:39 +0000</pubDate>
      <link>https://dev.to/paidandfiled/making-a-calculator-other-sites-can-embed-on-a-fully-static-site-316h</link>
      <guid>https://dev.to/paidandfiled/making-a-calculator-other-sites-can-embed-on-a-fully-static-site-316h</guid>
      <description>&lt;p&gt;I run a small site for freelancers, &lt;a href="https://paidandfiled.com/" rel="noopener noreferrer"&gt;Paid &amp;amp; Filed&lt;/a&gt;, and its most useful page is a free &lt;a href="https://paidandfiled.com/quarterly-tax-calculator/" rel="noopener noreferrer"&gt;1099 tax calculator&lt;/a&gt; that works out quarterly estimated tax. Bloggers, coworking spaces and accountants who write for freelancers kept wanting to put it on their own pages, so I made it embeddable.&lt;/p&gt;

&lt;p&gt;The site is a static Next.js export served from a Cloudflare Worker: no backend, no API keys, no per-site accounts. Here's how the embed works within those limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The embed is just a second route
&lt;/h2&gt;

&lt;p&gt;The calculator is one React component. The embeddable version is a separate route, &lt;code&gt;/embed/quarterly-tax-calculator/&lt;/code&gt;, that renders the same component without the site header, footer or analytics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'1099 tax calculator for 2026'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow&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="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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;EmbeddedCalculator&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="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"px-3 pb-5 pt-4"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Calculator&lt;/span&gt; &lt;span class="n"&gt;embedded&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"mt-5 text-center text-[13px]"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nc"&gt;Free&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://paidandfiled.com/quarterly-tax-calculator/"&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"_blank"&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"noopener"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;1099&lt;/span&gt; &lt;span class="n"&gt;tax&lt;/span&gt; &lt;span class="n"&gt;calculator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="nc"&gt;Paid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Filed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmbedHeight&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The embed route is &lt;code&gt;noindex&lt;/code&gt;.&lt;/strong&gt; It's set in the page metadata and again as an &lt;code&gt;X-Robots-Tag&lt;/code&gt; header for &lt;code&gt;/embed/*&lt;/code&gt;, so search engines send people to the real calculator page rather than a stripped-down duplicate. &lt;code&gt;follow&lt;/code&gt; stays on, so the links inside still count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No trackers in the frame.&lt;/strong&gt; A layout check turns off the analytics beacon and the ad-network tag on embed routes. Someone embedding a calculator shouldn't be adding third-party tracking to their own visitors without knowing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Auto-height without a library
&lt;/h2&gt;

&lt;p&gt;Iframes don't size themselves to their content. A fixed height either cuts off the results or leaves a big gap, and this calculator changes height as you open sections.&lt;/p&gt;

&lt;p&gt;The embedded page reports its height to the parent whenever it changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;EmbedHeight&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="nf"&gt;useEffect&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="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="n"&gt;main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'main'&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not in a frame&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;send&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="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;pfHeight&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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="n"&gt;height&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="s1"&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="n"&gt;ro&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;ResizeObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;send&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="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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="p"&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="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="kc"&gt;null&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;It measures &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;, not &lt;code&gt;document.body&lt;/code&gt;. Inside an iframe, the body is always at least as tall as the frame itself, so measuring it means the frame can grow but never shrink.&lt;/p&gt;

&lt;p&gt;It posts to &lt;code&gt;'*'&lt;/code&gt; because it can't know who embedded it, and that's fine here because the message is a single number and nothing sensitive.&lt;/p&gt;

&lt;p&gt;The host page gets one line of script as part of the embed code:&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;iframe&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://paidandfiled.com/embed/quarterly-tax-calculator/"&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1099 tax calculator for 2026"&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"width:100%;height:1650px;border:0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-size:14px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;1099 tax calculator by &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://paidandfiled.com/quarterly-tax-calculator/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Paid &lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt; Filed&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&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="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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'https://paidandfiled.com'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pfHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'iframe[src^="https://paidandfiled.com/embed/"]'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&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="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="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contentWindow&lt;/span&gt; &lt;span class="o"&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;source&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="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pfHeight&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'px'&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="p"&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiving side is where the checks belong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;e.origin&lt;/code&gt;&lt;/strong&gt; must be the calculator's origin, so another frame on the page can't resize things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;f.contentWindow === e.source&lt;/code&gt;&lt;/strong&gt; matches the message to the exact iframe that sent it, so two embeds on one page don't fight.&lt;/li&gt;
&lt;li&gt;The script is optional. Without it the iframe keeps its fixed height and scrolls, which is ugly but works. Plenty of CMSs strip &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; from posts, so the fallback matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The credit link is a plain brand link
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; under the iframe is an ordinary link in the host page that credits the tool by name. It's tempting to make that link anchor text like "best quarterly tax calculator", but Google's spam policies call out keyword-rich links in widgets and embeds that are distributed across many sites. A plain brand-name link is honest attribution, and the host can remove it or add &lt;code&gt;rel="nofollow"&lt;/code&gt; if they prefer. The embed works the same either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy is easy when there's no server
&lt;/h2&gt;

&lt;p&gt;Because the calculator is pure client-side math on figures from the IRS, nothing the visitor types leaves the browser, inside the iframe or out. That's a big part of why people are comfortable embedding it: no data processing agreement, no cookie banner implications from the frame itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;The embed code lives in a copy box on the &lt;a href="https://paidandfiled.com/quarterly-tax-calculator/" rel="noopener noreferrer"&gt;calculator page&lt;/a&gt;, free to use. If you've built embeddable widgets on static hosting and handled auto-height differently, I'd like to hear how in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>seo</category>
    </item>
    <item>
      <title>Parsing bank statement PDFs in the browser, with a CSP that proves nothing gets uploaded</title>
      <dc:creator>Brandon Sooknanan</dc:creator>
      <pubDate>Sun, 15 Nov 2026 01:49:18 +0000</pubDate>
      <link>https://dev.to/paidandfiled/parsing-bank-statement-pdfs-in-the-browser-with-a-csp-that-proves-nothing-gets-uploaded-4772</link>
      <guid>https://dev.to/paidandfiled/parsing-bank-statement-pdfs-in-the-browser-with-a-csp-that-proves-nothing-gets-uploaded-4772</guid>
      <description>&lt;p&gt;People who freelance often can't get a CSV out of their bank. Some banks only give PDF statements, others limit downloads to 90 days, and a few dropped CSV and QFX exports entirely. But every bookkeeping spreadsheet wants rows of &lt;code&gt;Date, Description, Amount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The usual fix is an online converter, which means uploading a year of bank statements to a stranger's server. I didn't want to ask anyone to do that, so the converter I built for &lt;a href="https://paidandfiled.com/" rel="noopener noreferrer"&gt;Paid &amp;amp; Filed&lt;/a&gt; runs entirely in the browser tab: &lt;a href="https://paidandfiled.com/bank-statement-converter/" rel="noopener noreferrer"&gt;paidandfiled.com/bank-statement-converter&lt;/a&gt;. This post covers the three parts that were harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Turning pdf.js text runs back into lines
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;page.getTextContent()&lt;/code&gt; doesn't give you lines. It gives you runs of text, each with a transform matrix, in whatever order the PDF generator wrote them. A single statement row like&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;03-14  ACH DEPOSIT CLIENT CO       1,250.00     4,310.27
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can arrive as six runs, out of order, with the minus sign of a debit split into its own run.&lt;/p&gt;

&lt;p&gt;So the first step rebuilds lines geometrically: sort by baseline (&lt;code&gt;transform[5]&lt;/code&gt;, top to bottom) then x position, group runs whose baselines are within 2 points, and join runs that physically touch without a space so a word pdf.js split stays whole.&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;itemsToLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PdfTextItem&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="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;str&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&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="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;!&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="o"&gt;!&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;str&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="p"&gt;)&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&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="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="n"&gt;runs&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="o"&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="n"&gt;run&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;runs&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="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&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="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="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runs&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="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;lines&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;run&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="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="n"&gt;lines&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="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="p"&gt;}&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;let&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&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="n"&gt;run&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;runs&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;touching&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;touching&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&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="n"&gt;text&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="o"&gt;/&lt;/span&gt;\&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;+/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A page that comes back with fewer than 40 non-space characters is a scan or a photo. There's no text layer to read, so the UI says so instead of returning an empty file.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One generic reader instead of a template per bank
&lt;/h2&gt;

&lt;p&gt;The obvious design is a template per bank. I started from code that worked that way, and it falls apart quickly: the same bank changes layout between checking and savings, between years, and between the web download and the mailed PDF.&lt;/p&gt;

&lt;p&gt;The generic rule turned out to be simple. &lt;strong&gt;A transaction line starts with a date and ends with one or two amounts.&lt;/strong&gt; If there are two, the last one is the running balance. Everything in between is the description. Dates can be &lt;code&gt;03/14&lt;/code&gt;, &lt;code&gt;03-14&lt;/code&gt;, &lt;code&gt;Mar 14&lt;/code&gt; or &lt;code&gt;14 Mar 2026&lt;/code&gt;, and when a row has no year it comes from the statement period printed in the header, with care for December statements that run into January.&lt;/p&gt;

&lt;p&gt;The hard part is the sign. Bank PDFs show direction in at least five ways: separate debit and credit columns (which collapse into one column once you've flattened the page to text), a trailing minus (&lt;code&gt;45.10-&lt;/code&gt;), &lt;code&gt;CR&lt;/code&gt; and &lt;code&gt;DR&lt;/code&gt; codes, section headings like "Deposits and other credits", or nothing at all. So direction is decided in priority order:&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;let&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'in'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;'out'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. The running balance: if it went up by exactly this amount, it's money in.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;running&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&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="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;running&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="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isCard&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'out'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'in'&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isCard&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'in'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'out'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// 2. CR / DR / DEP / WD codes next to the amount.&lt;/span&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="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// 3. A printed sign (a minus means money out on a bank statement, a payment on a card).&lt;/span&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="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;printedSign&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// 4. The section heading the row sits under.&lt;/span&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="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// 5. Give up, call it money out, and flag it for the user.&lt;/span&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="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'out'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;guessed&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything is compared in integer cents.&lt;/strong&gt; &lt;code&gt;0.1 + 0.2&lt;/code&gt; problems are real when you're summing 300 rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credit cards run backwards.&lt;/strong&gt; A purchase raises the balance on a card and lowers it on a checking account, so the reader decides "card or bank" first. It looks for phrases like "minimum payment due" or "credit limit", but only &lt;em&gt;before&lt;/em&gt; the first transaction row. A savings statement with a line-of-credit footer under it was read as a card, with every sign flipped, until that rule went in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The balance check
&lt;/h2&gt;

&lt;p&gt;Statements print an opening and a closing balance. After parsing, the reader checks that opening plus money in minus money out equals the closing balance, to the cent. The UI shows a green "matches the statement" or the exact difference. That one check catches nearly every parsing bug: a row that wrapped onto two lines, a page footer read as a transaction, a sign guessed wrong.&lt;/p&gt;

&lt;p&gt;When some rows had to be guessed and the totals don't add up, it tries one more thing: if flipping &lt;em&gt;all&lt;/em&gt; the guessed rows to money in makes the balance match, it does that. It's a cheap heuristic that fixed a whole class of statements with unlabeled credit columns.&lt;/p&gt;

&lt;p&gt;On real statements from one credit union (29 PDFs covering two years of checking, savings and loan statements), every checking statement matched to the cent on the first run. That test also exposed the card/savings bug above and the fact that loan statements have nothing to convert, so they now get their own message instead of a silent empty result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it never uploads anything
&lt;/h2&gt;

&lt;p&gt;"Your file never leaves your browser" is easy to write and impossible for a visitor to verify. So the page carries a Content Security Policy that makes it true at the browser level. The site is static files on a Cloudflare Worker, and this lives in &lt;code&gt;_headers&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/bank-statement-converter/
  Content-Security-Policy: connect-src 'self' https://cloudflareinsights.com; form-action 'self'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connect-src&lt;/code&gt; covers &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt;, WebSockets, &lt;code&gt;sendBeacon&lt;/code&gt; and &lt;code&gt;EventSource&lt;/code&gt;, so the only place this page &lt;em&gt;can&lt;/em&gt; send data is its own origin (which serves static files and has no upload endpoint) and the privacy-friendly analytics beacon. A future change that accidentally added a third-party script with tracking would be blocked by the browser, and it would show up in DevTools as a CSP violation.&lt;/p&gt;

&lt;p&gt;A few things I had to get right for that to hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pdf.js is self-hosted&lt;/strong&gt; (copied from &lt;code&gt;node_modules&lt;/code&gt; into &lt;code&gt;/public/pdfjs/&amp;lt;version&amp;gt;/&lt;/code&gt; at build time), worker included, so loading it isn't a cross-origin request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isEvalSupported: false&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;getDocument&lt;/code&gt;, which keeps pdf.js from compiling fonts with &lt;code&gt;new Function()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links into the page are plain &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tags, not client-side router links.&lt;/strong&gt; A client-side navigation never loads the page fresh, so the browser never applies the page's own CSP header. A full page load does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Pinterest tag the rest of the site uses is skipped&lt;/strong&gt; on this path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Password-protected PDFs work too. pdf.js throws a &lt;code&gt;PasswordException&lt;/code&gt; and the UI asks for the password, which goes straight back into &lt;code&gt;getDocument&lt;/code&gt; and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The output
&lt;/h2&gt;

&lt;p&gt;The result downloads as CSV or Excel, or copies as tab-separated text so a paste into column A of a spreadsheet fills three columns. The &lt;code&gt;.xlsx&lt;/code&gt; is written in the browser as well, with a small zip writer, so nothing round-trips to a server for that either.&lt;/p&gt;

&lt;p&gt;If you want to try it, there's a "made-up statement" button on &lt;a href="https://paidandfiled.com/bank-statement-converter/" rel="noopener noreferrer"&gt;the converter page&lt;/a&gt;, so you don't need to feed it your own bank PDF to see how it works. It's free with no sign-up. Paid &amp;amp; Filed is the small business I run that makes a self-employed tax workbook, and the converter exists because buyers kept getting stuck at the "now paste your bank CSV" step.&lt;/p&gt;

&lt;p&gt;Questions about the parser or the CSP approach are welcome in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pdf</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
