<?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: Ritusmoi Kaushik</title>
    <description>The latest articles on DEV Community by Ritusmoi Kaushik (@ritusmoikaushik).</description>
    <link>https://dev.to/ritusmoikaushik</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%2F3890265%2Feccb3254-aef7-4c24-ba6f-6ee8563a0847.jpg</url>
      <title>DEV Community: Ritusmoi Kaushik</title>
      <link>https://dev.to/ritusmoikaushik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ritusmoikaushik"/>
    <language>en</language>
    <item>
      <title>I built a HEIC to JPG converter that never uploads your photos (WebAssembly + libheif)</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 14 Jul 2026 06:08:42 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/i-built-a-heic-to-jpg-converter-that-never-uploads-your-photos-webassembly-libheif-291m</link>
      <guid>https://dev.to/ritusmoikaushik/i-built-a-heic-to-jpg-converter-that-never-uploads-your-photos-webassembly-libheif-291m</guid>
      <description>&lt;p&gt;&lt;em&gt;The privacy problem with "online HEIC converters", and how running libheif in the browser fixes it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you've ever helped a non-technical person get an iPhone photo to open on a Windows laptop, you've hit HEIC. Since iOS 11, iPhones save photos as &lt;code&gt;.heic&lt;/code&gt; by default — roughly half the size of JPG at the same quality — and the rest of the computing world still chokes on it. Windows shows a black thumbnail, upload forms reject the extension, older apps just shrug.&lt;/p&gt;

&lt;p&gt;The usual fix is to Google "HEIC to JPG converter", click the first result, and upload your photos to a server you know nothing about. That's the part that always bothered me. A camera roll isn't holiday snaps — it's ID scans, medical documents, kids, things people photographed &lt;em&gt;because&lt;/em&gt; they were private. Shipping those to a stranger's box for a format conversion, then trusting an unverifiable "deleted after 1 hour" banner, is a bad trade.&lt;/p&gt;

&lt;p&gt;So I built the converter to run &lt;strong&gt;entirely in the browser&lt;/strong&gt; — no upload, no server, nothing leaves the device. Here's how it works and what tripped me up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: decode HEIC where the file already is
&lt;/h2&gt;

&lt;p&gt;HEIC is HEVC-compressed image data in a container. Browsers can't decode it natively (that's the whole problem), so you ship the decoder yourself. Under the hood that's &lt;a href="https://github.com/strukturag/libheif" rel="noopener noreferrer"&gt;libheif&lt;/a&gt; compiled to WebAssembly — I use the &lt;a href="https://github.com/alexcorvi/heic2any" rel="noopener noreferrer"&gt;&lt;code&gt;heic2any&lt;/code&gt;&lt;/a&gt; wrapper around it, which handles the decode-and-re-encode in one call.&lt;/p&gt;

&lt;p&gt;The flow is entirely client-side — no &lt;code&gt;fetch&lt;/code&gt;, no upload endpoint, no backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File (.heic)
  → heic2any (libheif WASM) decodes + re-encodes
  → Blob (image/jpeg or image/png)
  → URL.createObjectURL → download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can prove it: open DevTools → Network tab, drop a file, and watch nothing happen. The page loads, then silence.&lt;/p&gt;

&lt;p&gt;The whole conversion is a few lines. Two decisions matter: &lt;strong&gt;lazy-load the decoder&lt;/strong&gt; and &lt;strong&gt;process files one at a time&lt;/strong&gt;.&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;MAX_INPUT_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&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;processFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Reject oversized files up front — HEIC decode is memory-heavy (see below)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tooBig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_INPUT_BYTES&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;tooBig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tooBig&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; is over 25MB.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Dynamic import: the WASM decoder only downloads when someone actually&lt;/span&gt;
  &lt;span class="c1"&gt;// converts — it stays out of the initial page bundle entirely.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heic2any&lt;/span&gt; &lt;span class="o"&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;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heic2any&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// Sequential, NOT Promise.all — a parallel batch blows the heap (see below)&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;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&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;blob&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;heic2any&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;toType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// "image/jpeg" | "image/png"&lt;/span&gt;
      &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quality&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="c1"&gt;// slider, defaults to 0.9&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;finalBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;blob&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;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// heic2any can return Blob[]&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;url&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="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalBlob&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;h2&gt;
  
  
  Three things that tripped me up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The first conversion is slow — and that's fine.&lt;/strong&gt; The libheif WASM module is chunky (over a megabyte). Because it's behind a dynamic &lt;code&gt;import()&lt;/code&gt;, it only downloads the first time someone actually converts — it never touches the initial page load — and the browser caches it after that, so every conversion afterward feels instant. Compared to the typical converter site loading 4–6 MB of ad scripts before you can even drop a file, one lazy-loaded module is a good deal. I just made the "first HEIC takes longer (decoder loading)" state explicit in the UI so the first hit doesn't feel broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Memory, not CPU, is the real ceiling.&lt;/strong&gt; Decoding HEIC allocates a full RGBA bitmap — width × height × 4 bytes. A 48 MP photo is ~190 MB decoded, before you've even re-encoded it. On desktop, fine. On a mobile Safari tab with a hard memory cap, big files kill the tab. So I cap input at 25 MB per file and run the batch &lt;strong&gt;sequentially&lt;/strong&gt; — the &lt;code&gt;for … await&lt;/code&gt; loop above, not &lt;code&gt;Promise.all&lt;/code&gt; — so ten files don't allocate ten bitmaps at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Lossy → lossy needs a quality escape hatch.&lt;/strong&gt; HEIC → JPG re-encodes already-decoded pixels into another lossy format. At &lt;code&gt;quality: 0.9&lt;/code&gt; it's visually identical for photos; below ~0.8 you start seeing it on smooth gradients (sky, skin). I exposed a quality slider and default it to 90, and offer PNG for anyone who wants zero loss after the decode step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother when Apple's "Most Compatible" setting exists
&lt;/h2&gt;

&lt;p&gt;You can tell an iPhone to save JPG going forward (Settings → Camera → Formats → Most Compatible), and everyone should. But that does nothing for the thousands of HEICs already in the camera roll — those still need converting, and that's the job this tool does without asking anyone to upload anything.&lt;/p&gt;

&lt;p&gt;If you want the deeper non-technical version of the HEIC-on-Windows mess — the black-thumbnail bug, the Microsoft extensions, why some files won't open even after you install everything — I wrote that up here: &lt;a href="https://fileoholic.com/blog/heic-files-windows/" rel="noopener noreferrer"&gt;Why HEIC files won't open on Windows&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And the tool itself, if you want to poke at it (or watch the Network tab stay empty): &lt;a href="https://fileoholic.com/tools/heic-to-jpg/" rel="noopener noreferrer"&gt;HEIC to JPG converter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;For a whole class of "convert this file" tools, the server is a liability, not a feature. WASM builds of the underlying C libraries — libheif, libvips, mozjpeg, pdfium — are good enough now that the file never has to leave the device. Faster for the user, zero privacy surface for you, and no infra to run. That's the direction I'm taking the rest of the tools too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What other file conversions are you still uploading to a server that could run locally? Curious what people are stuck on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Why Your iPhone Photos Won't Open on Windows (and the 5-Second Fix)</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Fri, 10 Jul 2026 08:57:07 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/why-your-iphone-photos-wont-open-on-windows-and-the-5-second-fix-55g9</link>
      <guid>https://dev.to/ritusmoikaushik/why-your-iphone-photos-wont-open-on-windows-and-the-5-second-fix-55g9</guid>
      <description>&lt;p&gt;&lt;strong&gt;Your phone saves them as HEIC. Your laptop shrugs. Here's what's actually going on — and how to stop fighting it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;About once a week, someone I know forwards me the same panicked message. iPhone in one hand, Windows laptop open in front of them, and a photo called something like &lt;code&gt;IMG_4821.heic&lt;/code&gt; that just… won't open. They double-click it. Nothing. They email it to themselves and try again. Same shrug from Windows. By the time they message me, they're half-convinced the photo is corrupted or their laptop is broken.&lt;/p&gt;

&lt;p&gt;It's neither. The photo is fine. The laptop is fine. What you're running into is a quiet decade-old standoff between Apple and the rest of the computing world, and once you understand it, the fix takes about five seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-sentence version of what's happening
&lt;/h2&gt;

&lt;p&gt;Since 2017, iPhones save photos as HEIC instead of JPG by default. HEIC is a genuinely better format — roughly half the file size at the same visible quality — which is why your 256 GB phone lasts as long as it does. The catch: Windows, most web upload forms, older email clients, government portals, and a long tail of everyday software never fully caught up. So the moment a HEIC file leaves the Apple ecosystem, it hits a wall.&lt;/p&gt;

&lt;p&gt;That's the whole problem. Not malware, not corruption, not your fault. Just a format your iPhone loves and your laptop doesn't recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Windows specifically chokes on it
&lt;/h2&gt;

&lt;p&gt;Windows didn't support HEIC natively until late in the Windows 10 cycle, and even then you had to go install an extension from the Microsoft Store to get it working. Windows 11 looks like it should handle HEIC out of the box — it ships with partial support — but in practice you'll still often see a black or blank thumbnail in Explorer, or the Photos app throwing an "unsupported format" error.&lt;/p&gt;

&lt;p&gt;And here's the part that trips people up even after they fix the viewing problem: &lt;strong&gt;being able to view a HEIC file and being able to upload one are two completely different things.&lt;/strong&gt; You can install every Microsoft extension there is, get gorgeous HEIC previews in Explorer, and still have a job-application form or a bank's KYC portal reject your &lt;code&gt;.heic&lt;/code&gt; on the spot — because those forms check the file extension, not whether your operating system happens to have a decoder. For anything you need to send or upload, JPG is still the only format that works everywhere, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: Stop the bleeding at the source
&lt;/h2&gt;

&lt;p&gt;If you regularly share photos with Windows users or upload to forms, the cleanest long-term fix is to tell your iPhone to just save JPG from now on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings → Camera → Formats → Most Compatible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every new photo from that point forward saves as a standard JPG. Old photos stay as HEIC, but you stop creating new ones. You give up a little of that storage efficiency, but for most people the "it just opens anywhere" tradeoff is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: Convert the HEIC photos you already have
&lt;/h2&gt;

&lt;p&gt;The setting above only helps going forward. For the photos already sitting in your camera roll, you need to convert them — and this is where most people make a small mistake that I'd rather you avoid.&lt;/p&gt;

&lt;p&gt;The typical move is to Google "HEIC to JPG converter," click the first result, and upload your photos to some random server for conversion. For holiday snaps, fine. But think about what's usually in a camera roll: ID scans, medical documents, kids, things you took a photo of specifically &lt;em&gt;because&lt;/em&gt; they were private. Uploading those to a stranger's server for a format conversion, and trusting an unverifiable "deleted after one hour" promise, is a worse deal than people realize.&lt;/p&gt;

&lt;p&gt;You don't have to. Modern browsers are fast enough to do the entire conversion locally, on your own device, without a single byte leaving your machine. I've been using &lt;a href="https://fileoholic.com/tools/heic-to-jpg/" rel="noopener noreferrer"&gt;Fileoholic's HEIC to JPG converter&lt;/a&gt; for exactly this — you drop the file in, it converts in the browser tab using a local WebAssembly decoder, and you download the JPG. No upload, no signup, no daily limit. If you're the paranoid type (healthy instinct), open your browser's DevTools, watch the Network tab, and drop a file: you'll see the page load and then silence. Your photo never goes anywhere.&lt;/p&gt;

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

&lt;p&gt;Apple made the right &lt;em&gt;technical&lt;/em&gt; call with HEIC. They arguably made the wrong &lt;em&gt;product&lt;/em&gt; call by flipping every iPhone to it by default in 2017, years before Windows, Android, and the web were ready. We've all been quietly cleaning up that mismatch ever since — which is why "convert HEIC to JPG" remains one of the most-searched file-format questions on the internet.&lt;/p&gt;

&lt;p&gt;If you want the deeper version of all this — the Windows 11 quirks, the black-thumbnail bug, the Microsoft extensions, and why some HEIC files still won't open even after you install everything — I wrote a full walkthrough here: &lt;a href="https://fileoholic.com/blog/heic-files-windows/" rel="noopener noreferrer"&gt;Why HEIC files won't open on Windows (and the simple fix)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But if you just need the photo to open right now: change the camera setting, convert the ones you already have in your browser, and get on with your day. It's a five-second tax for an inbox that just works.&lt;/p&gt;




</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Billing a Bulk Endpoint Where Any Row Might Be Free</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Fri, 10 Jul 2026 07:35:32 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/billing-a-bulk-endpoint-where-any-row-might-be-free-4kik</link>
      <guid>https://dev.to/ritusmoikaushik/billing-a-bulk-endpoint-where-any-row-might-be-free-4kik</guid>
      <description>&lt;p&gt;I already had per-invoice billing working: reserve a credit, do the expensive call, settle or refund. Then I added a bulk endpoint — paste a list, verify them all — and the neat single-item model fell apart, because in a batch some rows cost money and some are free, and you don't know which is which until you've done the work.&lt;/p&gt;

&lt;p&gt;This is the metering design behind the bulk GSTIN verification tool on &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;GSTExtract&lt;/a&gt; — a solo GST SaaS — and the two bugs I hit building it. The interesting part isn't the happy path. It's that "charge for a batch" quietly becomes "charge for the subset of the batch that turned out to be billable, without overselling and without spending the paid API on rows the user can't pay for."&lt;/p&gt;

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

&lt;p&gt;A user pastes up to 50 GST numbers. For each, I call a paid third-party registry API and return the business name, status, and address. Each fresh API call costs me real money, so the rule is &lt;strong&gt;1 credit per fresh successful lookup&lt;/strong&gt;. But three kinds of rows are free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cached&lt;/strong&gt; — I cache every result for 30 days. A repeat lookup of the same number costs me nothing, so it costs the user nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not found&lt;/strong&gt; — the API has no record. No data delivered, no charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalid format&lt;/strong&gt; — rejected by a regex before any call. Obviously free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a "50-item" request might bill for 12. And I can't know the 12 up front — cached vs fresh I can tell, but found vs not-found needs the call. That's the whole problem in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the single-item model doesn't lift
&lt;/h2&gt;

&lt;p&gt;My invoice path uses the classic &lt;strong&gt;reserve → settle → refund&lt;/strong&gt;: reserve one credit before the vision-model call, settle the real count after, refund on failure. For a batch, the temptation is to do it once for the whole thing: read balance, fetch everyone, charge the found count at the end.&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="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;to_fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;bal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;              &lt;span class="c1"&gt;# cap to what they can afford
&lt;/span&gt;&lt;span class="n"&gt;results&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;fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_fetch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&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;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_partial&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the hole
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This oversells. Two concurrent requests both read &lt;code&gt;bal = 10&lt;/code&gt;, both fetch 10, both try to charge — the first charges 10, the second charges 0 because &lt;code&gt;allow_partial&lt;/code&gt; floors at zero, and that second user just got 10 paid API calls for free. It's the exact race the single-item path was designed to avoid, reintroduced the moment I batched it. Read-then-write is not atomic no matter how you dress it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve per item, refund the misses
&lt;/h2&gt;

&lt;p&gt;The fix is to keep the atomic reserve, but do it &lt;strong&gt;per GSTIN&lt;/strong&gt;, and refund the ones that turn out free:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gstinbulk:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# atomic reserve of 1 credit — a single conditional UPDATE, gated on rowcount,
&lt;/span&gt;    &lt;span class="c1"&gt;# so two concurrent reserves for the last credit can't both win
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;reserve_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&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;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deferred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# out of credits — don't call the API
&lt;/span&gt;    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                          &lt;span class="c1"&gt;# bound API concurrency
&lt;/span&gt;        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;found&lt;/span&gt;&lt;span class="sh"&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;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;               &lt;span class="c1"&gt;# keep the reserved credit
&lt;/span&gt;    &lt;span class="nf"&gt;refund_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# not-found / failed = free
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each item reserves before its own paid call. A reserve failure means the balance is gone — that row is deferred, and crucially &lt;strong&gt;no API call is made for it&lt;/strong&gt;, so you never burn the paid quota on a row nobody paid for. Found rows keep the reserve. Everything else refunds. The billable total is just the count of &lt;code&gt;"charged"&lt;/code&gt;, and it's correct under concurrency because the atomicity lives in the credit reserve, not in a Python-side balance read.&lt;/p&gt;

&lt;p&gt;The reserve itself is a conditional update, not a read-modify-write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;credit_lots&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remaining&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lot&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gate on &lt;code&gt;rowcount&lt;/code&gt;: exactly one of two racing reserves for the last credit gets &lt;code&gt;rowcount = 1&lt;/code&gt;; the other gets &lt;code&gt;0&lt;/code&gt; and is told it's out of credits. SQLite serializes writers, so this holds without an explicit lock.&lt;/p&gt;

&lt;p&gt;One more subtlety: the refund on a not-found must &lt;strong&gt;not&lt;/strong&gt; feed the abuse counter. My invoice path counts a refunded failure toward a per-user daily "unreadable file" budget — a junk-upload guard. A not-found GSTIN is a perfectly normal result, not abuse, so bulk uses a plain reserve-release, not the failure-counting refund. Same primitive, different accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that actually cost me an hour
&lt;/h2&gt;

&lt;p&gt;My first version ran the reserve &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;asyncio.to_thread&lt;/code&gt;, alongside the fetch, thinking "offload all the blocking work." The tests immediately threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite3.InterfaceError: bad parameter or other API misuse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reserves were now happening on worker threads, several at once, against one SQLite connection. A SQLite connection isn't safe to share across threads like that. The fetch — an HTTP call — genuinely belongs in a thread. The DB writes do not.&lt;/p&gt;

&lt;p&gt;The fix is a one-line conceptual rule: &lt;strong&gt;DB stays on the event loop; only the slow HTTP call is offloaded.&lt;/strong&gt;&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;reserve_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;     &lt;span class="c1"&gt;# sync, on the loop — fast local write
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deferred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# only this leaves the loop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reserves are quick local writes; running them on the loop serializes them naturally and keeps them single-threaded, which is exactly what SQLite wants. The concurrency I actually needed was for the network calls, and those are the only thing that fans out now. This mirrors how the invoice path already worked — I'd just forgotten why it worked when I reached for &lt;code&gt;to_thread&lt;/code&gt; on the DB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free tier, and one export gotcha
&lt;/h2&gt;

&lt;p&gt;Anonymous users get a small daily cap of free fresh lookups (for the SEO/demo value), metered per IP; cached hits don't count, so re-checking the same list is always free. Logged-in users draw from credits as above.&lt;/p&gt;

&lt;p&gt;And a non-billing one worth flagging: the results export to an &lt;code&gt;.xlsx&lt;/code&gt;, and the values include names pulled from the registry plus, in the export path, client-submitted rows. A cell value starting with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, or &lt;code&gt;@&lt;/code&gt; is a formula-injection vector when the file is opened. Prefix those with a quote:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&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="ow"&gt;in&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="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="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="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="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cheap, and it turns a "download to Excel" feature into one that can't ship a live formula from untrusted input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;"Meter a batch" is three problems wearing one coat: the count is unknown until the work runs, some of the work is free, and it all happens concurrently. Reserve per item and the three collapse into one — each row gates itself, pays for itself, or refunds itself, and the total falls out correct. Keep the DB writes on the loop and let only the network fan out.&lt;/p&gt;

&lt;p&gt;If you want to see the endpoint from the outside, it's the &lt;a href="https://gstextract.com/bulk-gstin-verification" rel="noopener noreferrer"&gt;bulk GSTIN verification tool&lt;/a&gt;; the invoice-extraction engine it sits next to is &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;open source&lt;/a&gt;. The billing primitives are the same ones from &lt;a href="https://dev.to/ritusmoikaushik/metering-a-paid-api-without-overselling-the-credit-ledger-behind-a-solo-saas-3jej"&gt;the earlier write-up on the single-item ledger&lt;/a&gt; — this is what happened when I pointed them at a batch.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>asyncio</category>
      <category>saas</category>
    </item>
    <item>
      <title>Metering a Paid API Without Overselling: the Credit Ledger Behind a Solo SaaS</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 16 Jun 2026 07:10:36 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/metering-a-paid-api-without-overselling-the-credit-ledger-behind-a-solo-saas-3jej</link>
      <guid>https://dev.to/ritusmoikaushik/metering-a-paid-api-without-overselling-the-credit-ledger-behind-a-solo-saas-3jej</guid>
      <description>&lt;p&gt;I charge per invoice extracted. One upload of a five-invoice PDF should cost five credits, a failed extraction should cost nothing, and a user with one credit left must never get two. That last rule is where it got interesting.&lt;/p&gt;

&lt;p&gt;This is the billing design behind &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;GSTExtract&lt;/a&gt; — a tool that reads Indian GST invoice PDFs into Excel — and the concurrency bug a second-pass audit found in it. Writing it up because "meter a paid API correctly" is one of those problems that looks trivial until you hold it up to the light.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;Each extraction calls a vision model, which costs me real money. So the user spends a credit per invoice. The catch: I don't know how many invoices are in a PDF until &lt;em&gt;after&lt;/em&gt; the model reads it. A single page can hold three invoices; a "10-page" file might be one. I can't charge up front because I don't know the count, and I can't charge after without a window where a user with a zero balance has already burned my API budget.&lt;/p&gt;

&lt;p&gt;The answer most billing systems land on is &lt;strong&gt;reserve → settle → refund&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reserve&lt;/strong&gt; one credit before doing the expensive work. This is the gate — if it fails, the user is out of credits and nothing runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settle&lt;/strong&gt; after success: charge the &lt;em&gt;actual&lt;/em&gt; count minus the one already reserved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refund&lt;/strong&gt; the reserve if the extraction failed, so failures are never billed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Money and credits are integers. Every change is also written to an append-only ledger with an idempotency key, so a retry can never double-charge. Balance is derived: the sum of remaining credits across non-expired "lots" (a lot is one grant — a signup bonus, a purchase). Consumption is FIFO by soonest expiry, so credits get used before they lapse.&lt;/p&gt;

&lt;p&gt;That part worked. The bug was in the word "atomic."&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: two uploads, one credit
&lt;/h2&gt;

&lt;p&gt;The original charge function did the obvious thing:&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="c1"&gt;# read the balance
&lt;/span&gt;&lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="c1"&gt;# ... then walk the lots and decrement in Python
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lot&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;take&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;-- writes an ABSOLUTE value
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the balance, decide, write the new value. Single-threaded, this is fine. Now picture a user with exactly &lt;strong&gt;one&lt;/strong&gt; credit firing two uploads at the same moment (a double-click, or two tabs):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request A reads &lt;code&gt;remaining = 1&lt;/code&gt;. Decides it's allowed.&lt;/li&gt;
&lt;li&gt;Request B reads &lt;code&gt;remaining = 1&lt;/code&gt;. Also decides it's allowed.&lt;/li&gt;
&lt;li&gt;A writes &lt;code&gt;remaining = 0&lt;/code&gt;, inserts a &lt;code&gt;-1&lt;/code&gt; ledger row.&lt;/li&gt;
&lt;li&gt;B writes &lt;code&gt;remaining = 0&lt;/code&gt;, inserts a &lt;code&gt;-1&lt;/code&gt; ledger row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both succeed. The user spent one credit and got two extractions. The idempotency key didn't save me — the two requests had &lt;em&gt;different&lt;/em&gt; keys, because they're two genuinely different uploads. The read-then-write-absolute is the classic lost-update race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: let the database do the deciding
&lt;/h2&gt;

&lt;p&gt;The cure is to stop computing the new value in Python and let the write itself be the gate — a conditional &lt;code&gt;UPDATE&lt;/code&gt; that only fires if there's still enough left, checked under the row lock:&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="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lot_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;take&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="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&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="n"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;      &lt;span class="c1"&gt;# we actually got it
# rowcount 0 → someone else took it first; move on or roll back
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remaining = remaining - take&lt;/code&gt; instead of &lt;code&gt;remaining = &amp;lt;number I computed&amp;gt;&lt;/code&gt;. The &lt;code&gt;WHERE remaining &amp;gt;= take&lt;/code&gt; re-checks the balance &lt;em&gt;at write time&lt;/em&gt;, and SQLite serializes writers, so the second request blocks, re-evaluates against the now-committed &lt;code&gt;remaining = 0&lt;/code&gt;, gets &lt;code&gt;rowcount = 0&lt;/code&gt;, and is correctly told it can't reserve. Exactly one upload wins. Balance can never go negative.&lt;/p&gt;

&lt;p&gt;I proved it with a test that fires N concurrent reservers at a one-credit account on a real file-backed SQLite DB (WAL mode, busy timeout — same as production, because an in-memory shared-connection DB won't reproduce writer contention). Exactly one succeeds, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: a retry that costs me money
&lt;/h2&gt;

&lt;p&gt;There was a sneakier one, found in the same audit. The workspace retries an upload once on a dropped connection, reusing the same &lt;code&gt;request_id&lt;/code&gt; so it doesn't double-charge. And it didn't double-charge — the ledger keys saw to that. But the idempotency cache was written &lt;em&gt;after&lt;/em&gt; the model call, not before. So a retry that arrived while the first call was still in flight sailed past the cache check and fired a &lt;strong&gt;second&lt;/strong&gt; vision-model call. No double charge, but I paid for two API calls and logged a duplicate usage event.&lt;/p&gt;

&lt;p&gt;Fix: mark the request in-flight &lt;em&gt;before&lt;/em&gt; the expensive call, while everything above it is still synchronous (so two requests can't both pass the check), and have a retry return &lt;code&gt;409&lt;/code&gt; instead of re-running:&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="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_REQUESTS&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="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_flight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# ... reserve the credit ...
&lt;/span&gt;&lt;span class="n"&gt;_REQUESTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rid&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_flight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# before the model call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson that keeps repeating: &lt;strong&gt;idempotency has to cover the side effect, not just the database row.&lt;/strong&gt; The charge was idempotent. The expensive API call wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an append-only ledger earns its keep
&lt;/h2&gt;

&lt;p&gt;The thing that made all of this debuggable: nothing is ever updated or deleted in the transaction log. Every grant, charge, and refund is an immutable row with a signed delta. That gives you a reconciliation invariant you can assert on a schedule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the sum of all ledger deltas for a user == the sum of &lt;code&gt;remaining&lt;/code&gt; across all their lots&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If those two numbers ever disagree, a write went wrong, and an alert fires. (I also learned to &lt;em&gt;not&lt;/em&gt; fold a cache-freshness check into that alert — a balance cache that goes stale when a credit quietly expires is not corruption, and it had me chasing phantom "drift" emails for a day.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't read-modify-write a balance. Make the write conditional (&lt;code&gt;WHERE remaining &amp;gt;= n&lt;/code&gt;) and trust &lt;code&gt;rowcount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reserve → settle → refund cleanly separates "can they pay" from "what did it actually cost."&lt;/li&gt;
&lt;li&gt;Idempotency must guard the &lt;em&gt;expensive side effect&lt;/em&gt;, not only the ledger insert.&lt;/li&gt;
&lt;li&gt;An append-only ledger gives you a cheap, always-true invariant to reconcile against.&lt;/li&gt;
&lt;li&gt;Write the concurrency test against a real on-disk DB, or it won't reproduce the race you're trying to kill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hosted tool has a free daily tier and twenty free credits on signup, with prepaid packs for heavier use — so this ledger runs on every extraction, and I'd rather it be boringly correct. If you want to see it in action, it's at &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;gstextract.com&lt;/a&gt;; the extraction engine is &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;open source&lt;/a&gt;, though the billing layer above is part of the hosted product.&lt;/p&gt;

&lt;p&gt;If you've metered a paid API differently — especially how you handle the count-unknown-until-after problem — I'd genuinely like to hear it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Open-Sourced the Core of My GST Tool. Here's What I Kept Private.</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 21 Apr 2026 07:12:18 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/i-open-sourced-the-core-of-my-gst-tool-heres-what-i-kept-private-37kk</link>
      <guid>https://dev.to/ritusmoikaushik/i-open-sourced-the-core-of-my-gst-tool-heres-what-i-kept-private-37kk</guid>
      <description>&lt;p&gt;A few days ago I published the core extraction engine of GSTExtract on GitHub, MIT licensed. The &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;repository is here&lt;/a&gt;. It's not the full product. It's a seven-week-old snapshot, and that gap is the whole strategy.&lt;/p&gt;

&lt;p&gt;This post is about the decisions I made picking what to open-source and what to keep private. The deciding took longer than writing the code did, which surprised me. Writing it up in case anyone else is staring at the same question on their own project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context, Briefly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;GSTExtract&lt;/a&gt; reads Indian GST invoice PDFs and pulls out the fields (GSTIN, invoice number, amounts, taxes) into Excel. Small businesses and accountants use it at month-end to avoid hand-typing supplier bills into Tally. The stack is Python — pdfplumber for digital PDFs, Tesseract OCR as fallback, regex + keyword-anchored extraction, openpyxl for Excel output. FastAPI wraps it for the hosted version. The hosted tool is free during early access, with a paid tier planned for high-volume users later.&lt;/p&gt;

&lt;p&gt;So the question was: how do I open-source without giving away the commercial edge?&lt;/p&gt;

&lt;h2&gt;
  
  
  What Went Public
&lt;/h2&gt;

&lt;p&gt;The deterministic regex plus keyword extraction pipeline. GSTIN parsing with the Luhn mod-36 checksum. Zone segmentation that splits an invoice into header and footer and routes fields by zone. Tax-line detection with keyword anchoring. Excel export. The test suite. Basically, the commoditised parsing work that anyone doing invoice extraction would eventually re-implement themselves if they cared enough.&lt;/p&gt;

&lt;p&gt;If someone wants to understand &lt;em&gt;how&lt;/em&gt; you pull GSTINs and invoice totals out of a messy PDF, the code is all there. Forkable, patchable, usable in their own pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stayed Private
&lt;/h2&gt;

&lt;p&gt;The seven weeks of engine refinements since the cut point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-vendor accuracy tuning for Amazon, Flipkart, Swiggy, Zomato, BookMyShow, RedBus, and Myntra invoices (each one has a slightly different layout that breaks generic extraction)&lt;/li&gt;
&lt;li&gt;Table-based tax extraction for borderless PDFs&lt;/li&gt;
&lt;li&gt;Multi-invoice detection for combined bundles&lt;/li&gt;
&lt;li&gt;Per-field confidence scoring refinements&lt;/li&gt;
&lt;li&gt;Proprietorship invoice edge cases (handwritten-style templates that use bare "FROM" labels instead of "BILL FROM", single-digit invoice serials, and so on)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus the entire webapp: FastAPI layer, rate limiting, CSRF, file validation, the invoice validation gate that rejects credit notes and proforma bills, the batch upload flow, the learning-data logging. All of that stays closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Time-Lag
&lt;/h2&gt;

&lt;p&gt;This is the part I thought about most. Just publishing the latest engine would let anyone stand up a competing extraction site with the same accuracy I have. Publishing a snapshot from seven weeks ago means competitors get a working baseline, but not the current edge. The engineering work I do this month becomes public (maybe) in a few months, not immediately.&lt;/p&gt;

&lt;p&gt;Redis, MongoDB, and Elastic have all done versions of this. The open-core pattern: community gets a real, working version of the core. Commercial version stays ahead by some time delta. Nobody feels cheated, but the pricing power stays intact.&lt;/p&gt;

&lt;p&gt;For me, the specific cut was commit &lt;code&gt;0aa5f07&lt;/code&gt; from 2 March 2026, labeled "Phase 12 production hardening" in the private repo. Engine is solid from there. Everything after is refinement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Expect to Get From This
&lt;/h2&gt;

&lt;p&gt;Honestly, the single biggest thing is the backlink. GitHub has domain authority 96. Having a public repo that links to gstextract.com from the README gives my young domain an authority signal it couldn't easily get any other way. On a site that's only a couple of months old and trying to rank for competitive GST queries, that one dofollow link is legitimately meaningful.&lt;/p&gt;

&lt;p&gt;After that, in descending likelihood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credibility signal to technical readers who want to see how the extraction works. Some fraction of people evaluating a hosted tool will check if the code is worth trusting.&lt;/li&gt;
&lt;li&gt;Issues and pull requests from people hitting edge cases I haven't seen. GST invoice formats are wilder than you'd think. Someone will inevitably send me a PDF from a vendor I've never heard of that breaks something.&lt;/li&gt;
&lt;li&gt;Forks from people who need self-hosted extraction for their own use cases. Rare, but they become potential collaborators.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'm not expecting: hundreds of stars, viral adoption, a community forming around the repo. For a narrow Indian-GST tool, the realistic audience is small. A handful of serious users finding it and either using it or contributing is the bar, not hockey-stick open-source growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;What this might cost me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Competitors can clone and study the extraction logic. They would eventually anyway, but I've shortened the path.&lt;/li&gt;
&lt;li&gt;Time maintaining the public version even if nobody uses it. If I ignore it for months, it rots and becomes a bad signal.&lt;/li&gt;
&lt;li&gt;Some fraction of potential paying customers might think "well, the core is free, I'll just self-host" and walk away. I don't think this is many people, but it's nonzero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'm betting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The moat is distribution plus the continuous-improvement time-lag, not the code itself.&lt;/li&gt;
&lt;li&gt;Most competitors don't actually want to self-host and maintain a parser, deal with Tesseract and Poppler, manage their own uptime. Running the hosted tool is a service, not just code.&lt;/li&gt;
&lt;li&gt;People who'll pay for the hosted version are paying for the current-version edge, the no-setup convenience, and whatever the product becomes post-launch. The snapshot on GitHub is closer to educational than competitive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We'll see if that's right. If self-hosting forks start cutting into hosted usage in a measurable way, I'll revisit the cut-point. For now, the calculus feels fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;github.com/ritusmoikaushik/gstextract-core&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hosted tool: &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;gstextract.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and thinking through an open-core cut, happy to compare notes. And if you run into parsing edge cases on unusual invoice formats, open an issue on the repo.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>saas</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
