<?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: Carlos M. </title>
    <description>The latest articles on DEV Community by Carlos M.  (@cmartinezv).</description>
    <link>https://dev.to/cmartinezv</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%2F4106458%2F81149fd5-b355-4bc4-9378-0cdf1785212c.jpg</url>
      <title>DEV Community: Carlos M. </title>
      <link>https://dev.to/cmartinezv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cmartinezv"/>
    <language>en</language>
    <item>
      <title>Your AI will fail. Your billing system needs to know that.</title>
      <dc:creator>Carlos M. </dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:41:29 +0000</pubDate>
      <link>https://dev.to/cmartinezv/your-ai-will-fail-your-billing-system-needs-to-know-that-5075</link>
      <guid>https://dev.to/cmartinezv/your-ai-will-fail-your-billing-system-needs-to-know-that-5075</guid>
      <description>&lt;p&gt;I run &lt;a href="https://photoaistudio.com" rel="noopener noreferrer"&gt;Photo AI Studio&lt;/a&gt;, which turns a selfie into professional photos across ~49 themes. Users buy credits; one generation costs 100 of them.&lt;/p&gt;

&lt;p&gt;Simple product. The billing was the hardest part — and not for the reason I expected.&lt;/p&gt;

&lt;p&gt;Here's the thing nobody tells you when you put a price tag on a model call: &lt;strong&gt;the model fails.&lt;/strong&gt; Not often, but reliably often enough to matter. Provider timeouts. Safety filters tripping on a perfectly normal selfie. A GPU node dying mid-batch. Rate limits during a traffic spike.&lt;/p&gt;

&lt;p&gt;When a SaaS API call fails, you retry and nobody notices. When a &lt;em&gt;paid&lt;/em&gt; generation fails, someone just watched 100 credits disappear and got nothing. That's a refund request, a support ticket, and a chargeback — in that order, if you handle it badly.&lt;/p&gt;

&lt;p&gt;So the real question isn't "how do I charge for AI." It's: &lt;strong&gt;how do I charge for something that is allowed to fail?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: charge on request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deductCredits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;image&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;generateImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 💥&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fastest to build, and it's how a shocking number of AI products still work. The failure mode is obvious: the model throws, the credits are gone, and your &lt;code&gt;catch&lt;/code&gt; block is now responsible for making the user whole. Which means your refund logic is a second, parallel accounting system — one that runs only in the unhappy path, so it's the least-tested code you own.&lt;/p&gt;

&lt;p&gt;Every bug in it costs you real money or real trust. Usually both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 2: charge on success
&lt;/h2&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;image&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;generateImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deductCredits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the user is never wrongly charged. Instead you've built a free-generation machine.&lt;/p&gt;

&lt;p&gt;Nothing stops someone firing 50 concurrent requests with a 100-credit balance. Every one passes the balance check — the deduction hasn't happened yet on any of them. You eat 50 generations, bill them for one. I found this the fun way, on a Tuesday.&lt;/p&gt;

&lt;p&gt;You also can't answer "how many credits does this user have right now?" while jobs are in flight. Your balance is a lie for the entire duration of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works: reserve, then settle
&lt;/h2&gt;

&lt;p&gt;The pattern is older than AI and it's sitting in your card terminal — &lt;strong&gt;authorize and capture.&lt;/strong&gt; Your card gets held at the pump before anyone knows what you'll pump.&lt;/p&gt;

&lt;p&gt;Three states, not two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reserve&lt;/strong&gt; — atomically move 100 credits from &lt;code&gt;available&lt;/code&gt; into &lt;code&gt;held&lt;/code&gt;, before the model is touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settle&lt;/strong&gt; — generation succeeded: the hold converts to a spend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release&lt;/strong&gt; — generation failed: the hold reverses, credits are available again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The critical property: &lt;strong&gt;step 1 is the only place a concurrency check happens&lt;/strong&gt;, and it's a single atomic write. Steps 2 and 3 can't fail in a way that loses money, because they're just resolving something already recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't store a balance. Store a ledger.
&lt;/h2&gt;

&lt;p&gt;The mistake I'd make again if I weren't careful is a &lt;code&gt;users.credits&lt;/code&gt; integer column. It's one &lt;code&gt;UPDATE&lt;/code&gt; away from being wrong forever, and when a user asks "where did my credits go?" you have no answer.&lt;/p&gt;

&lt;p&gt;Append-only ledger instead. The balance is a derived value:&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;credit_entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;       &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount&lt;/span&gt;        &lt;span class="nb"&gt;int&lt;/span&gt;  &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- signed: -100 hold, +100 release&lt;/span&gt;
  &lt;span class="n"&gt;kind&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- purchase|hold|settle|release|grant&lt;/span&gt;
  &lt;span class="n"&gt;job_id&lt;/span&gt;        &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;-- the generation this belongs to&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- see below&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;credit_entries&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="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserving becomes one statement that either wins or does nothing:&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;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;credit_entries&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="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&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="s1"&gt;'hold'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;credit_entries&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows back means insufficient funds. No read-then-write race, no row lock held across a 30-second model call, and the sum is computed inside the same statement that writes. (Once your ledger gets big, keep a periodically-rolled-up snapshot row and sum only entries after it — don't scan a million rows to render a header.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency is not optional here
&lt;/h2&gt;

&lt;p&gt;Users double-click. Mobile clients retry on flaky connections. Provider webhooks fire twice — that's &lt;em&gt;documented behaviour&lt;/em&gt; at most of them, not a bug.&lt;/p&gt;

&lt;p&gt;Every write carries a caller-supplied key, unique per intent:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`hold:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&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="c1"&gt;// one hold per job, forever&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settleKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`settle:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;unique&lt;/code&gt; constraint does the enforcement. A duplicate insert throws, you catch the conflict, and you return the &lt;em&gt;existing&lt;/em&gt; result rather than doing the work again. This one constraint eliminated an entire genre of support ticket for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The genuinely hard part isn't technical
&lt;/h2&gt;

&lt;p&gt;Once holds work, you have to answer a policy question, and no amount of Postgres helps: &lt;strong&gt;what counts as a failure?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provider 500, timeout, node death → obviously refund. Not the user's fault.&lt;/li&gt;
&lt;li&gt;Safety filter rejects the upload → refund, but explain why, or you'll get the ticket anyway.&lt;/li&gt;
&lt;li&gt;The photo generated fine and the user just doesn't like their jawline → &lt;strong&gt;not&lt;/strong&gt; a failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is most of your inbound. And "no" is technically correct and commercially stupid, because the person is telling you your product didn't do what they hoped.&lt;/p&gt;

&lt;p&gt;What I landed on: full refunds are automatic and invisible for infra failures — the user often never learns a generation failed, because the retry lands before they notice. Taste complaints get a cheap regeneration instead. It costs me a fraction of a full refund, it converts a disappointed user into an engaged one, and it stopped the argument about whether AI output is "correct."&lt;/p&gt;

&lt;h2&gt;
  
  
  Sweep your stuck holds
&lt;/h2&gt;

&lt;p&gt;Processes die between reserve and settle. Do it enough times and users have credits locked in limbo, which reads exactly like theft to the person holding the account.&lt;/p&gt;

&lt;p&gt;A cron that releases holds with no terminal state after N minutes:&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="c1"&gt;-- any hold whose job never settled or released&lt;/span&gt;
&lt;span class="k"&gt;select&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;job_id&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;credit_entries&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;where&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;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'hold'&lt;/span&gt;
  &lt;span class="k"&gt;and&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;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'15 minutes'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;credit_entries&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
    &lt;span class="k"&gt;where&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;job_id&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;job_id&lt;/span&gt; &lt;span class="k"&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="n"&gt;kind&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settle'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'release'&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;Release each one, idempotently, with &lt;code&gt;release:${jobId}&lt;/code&gt;. That query is also the best health metric I have — if stuck holds spike, something upstream is broken and I know before anyone emails me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model the money as events, not as a number.&lt;/strong&gt; Every "where did my credits go" question becomes a &lt;code&gt;SELECT&lt;/code&gt; instead of an apology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the concurrency check in the write.&lt;/strong&gt; Read-then-write is a free-credits exploit with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume every mutation runs twice.&lt;/strong&gt; Because it will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide your refund policy before you write the refund code.&lt;/strong&gt; The code is a day. The policy is the actual product decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Non-determinism isn't an edge case in an AI product — it's the substrate. The billing layer is where that stops being a philosophical observation and starts costing you money.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://photoaistudio.com" rel="noopener noreferrer"&gt;Photo AI Studio&lt;/a&gt; solo — happy to answer anything about the stack, the economics, or what generation costs actually look like at volume. Ask below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>saas</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
