<?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: Indie Rob</title>
    <description>The latest articles on DEV Community by Indie Rob (@indierob_).</description>
    <link>https://dev.to/indierob_</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%2F4108728%2Fc6562aa5-9991-420b-9255-f9295d4d9c4a.jpg</url>
      <title>DEV Community: Indie Rob</title>
      <link>https://dev.to/indierob_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/indierob_"/>
    <language>en</language>
    <item>
      <title>I Made Claude Code Prove Billing End-to-End Before I Let It Ship</title>
      <dc:creator>Indie Rob</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:17:14 +0000</pubDate>
      <link>https://dev.to/indierob_/i-made-claude-code-prove-billing-end-to-end-before-i-let-it-ship-2h9d</link>
      <guid>https://dev.to/indierob_/i-made-claude-code-prove-billing-end-to-end-before-i-let-it-ship-2h9d</guid>
      <description>&lt;p&gt;I’ve been using coding agents for a while, but during the last release of a SaaS project I changed one thing about how I used them.&lt;/p&gt;

&lt;p&gt;I stopped defining “done” as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;implementation finished, tests passing&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and gave the agent an external condition instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Do not let me ship until you can prove that billing works end-to-end against real infrastructure.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That turned out to be much more useful than another round of code generation or code review.&lt;/p&gt;

&lt;p&gt;Because the tests were already green.&lt;/p&gt;

&lt;p&gt;The real system was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The release gate
&lt;/h2&gt;

&lt;p&gt;The project uses FastAPI, Next.js, Postgres, Redis, Celery and Paddle.&lt;/p&gt;

&lt;p&gt;Billing already had tests around checkout, webhook processing, subscriptions, credit grants and idempotency.&lt;/p&gt;

&lt;p&gt;At that point it would have been very easy to say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Looks good. Ship it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead, I asked Claude Code to prove the actual money path.&lt;/p&gt;

&lt;p&gt;That meant it couldn't stop after reading the code or running pytest.&lt;/p&gt;

&lt;p&gt;It had to create an environment where a real Paddle sandbox transaction could go through the application.&lt;/p&gt;

&lt;p&gt;So it provisioned temporary infrastructure on Render through the API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres&lt;/li&gt;
&lt;li&gt;Redis&lt;/li&gt;
&lt;li&gt;backend&lt;/li&gt;
&lt;li&gt;frontend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it configured a temporary Paddle sandbox webhook destination pointing at that deployment.&lt;/p&gt;

&lt;p&gt;The requirement was simple: act like a real customer and show me the resulting state.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real checkout, not a mocked one
&lt;/h2&gt;

&lt;p&gt;The agent then went through the actual application flow.&lt;/p&gt;

&lt;p&gt;It authenticated through the app, opened checkout, completed a Paddle sandbox payment and inspected what happened afterward.&lt;/p&gt;

&lt;p&gt;Not just the HTTP response.&lt;/p&gt;

&lt;p&gt;Not just the redirect.&lt;/p&gt;

&lt;p&gt;The resulting state.&lt;/p&gt;

&lt;p&gt;It checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Paddle webhook deliveries;&lt;/li&gt;
&lt;li&gt;the local subscription record;&lt;/li&gt;
&lt;li&gt;the credit ledger;&lt;/li&gt;
&lt;li&gt;the resulting account state;&lt;/li&gt;
&lt;li&gt;whether replaying a real webhook changed anything twice;&lt;/li&gt;
&lt;li&gt;whether a second checkout was incorrectly allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before payment the account was free, with no paid subscription.&lt;/p&gt;

&lt;p&gt;After payment it had the expected active subscription and the expected credit balance.&lt;/p&gt;

&lt;p&gt;So far, good.&lt;/p&gt;

&lt;p&gt;Then the sandbox exposed a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption hiding behind the code
&lt;/h2&gt;

&lt;p&gt;We had implicitly been thinking about these events as a sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subscription.created
        ↓
subscription.activated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That mental model was wrong.&lt;/p&gt;

&lt;p&gt;In the sandbox, the two webhook handlers could overlap.&lt;/p&gt;

&lt;p&gt;Both could begin while there was still no local subscription row.&lt;/p&gt;

&lt;p&gt;Conceptually, each handler was doing something like:&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;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_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;subscription&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;create_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfectly reasonable if the operations happen sequentially.&lt;/p&gt;

&lt;p&gt;Not safe if two different webhook events execute at the same time.&lt;/p&gt;

&lt;p&gt;The actual failure mode was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Handler A                       Handler B

SELECT subscription             SELECT subscription
→ nothing                       → nothing

INSERT subscription
                                INSERT subscription

success                         unique constraint violation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database was correctly preventing duplicate subscriptions.&lt;/p&gt;

&lt;p&gt;But one of the webhook handlers was now failing.&lt;/p&gt;

&lt;p&gt;And this was not an idempotency problem in the usual sense.&lt;/p&gt;

&lt;p&gt;These were two different Paddle events.&lt;/p&gt;

&lt;p&gt;Making each individual event idempotent did not prevent two distinct events from racing over the same underlying subscription.&lt;/p&gt;

&lt;p&gt;The deeper bug was our &lt;strong&gt;sequencing assumption&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tests didn't catch it
&lt;/h2&gt;

&lt;p&gt;Our tests had covered the individual operations.&lt;/p&gt;

&lt;p&gt;They had covered webhook replay.&lt;/p&gt;

&lt;p&gt;They had covered duplicate credit protection.&lt;/p&gt;

&lt;p&gt;What they had not challenged was the assumption that two different subscription events would effectively be handled one after another.&lt;/p&gt;

&lt;p&gt;That is the part I found interesting.&lt;/p&gt;

&lt;p&gt;The code was tested.&lt;/p&gt;

&lt;p&gt;The assumption connecting the pieces wasn't.&lt;/p&gt;

&lt;p&gt;A real external system invalidated it almost immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix was not to invent a more complicated application-level lock.&lt;/p&gt;

&lt;p&gt;We already had something very good at arbitrating concurrent writes:&lt;/p&gt;

&lt;p&gt;the database.&lt;/p&gt;

&lt;p&gt;The final pattern was roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;try to insert the subscription;&lt;/li&gt;
&lt;li&gt;let the unique constraint decide which concurrent transaction wins;&lt;/li&gt;
&lt;li&gt;isolate the conflicting insert inside a SAVEPOINT;&lt;/li&gt;
&lt;li&gt;roll back only that inner operation if the insert loses;&lt;/li&gt;
&lt;li&gt;re-read the subscription row created by the winner;&lt;/li&gt;
&lt;li&gt;continue processing normally.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The SAVEPOINT mattered.&lt;/p&gt;

&lt;p&gt;Without it, handling the uniqueness collision could poison the larger transaction and interfere with the rest of the webhook processing.&lt;/p&gt;

&lt;p&gt;The losing handler should not become an error path.&lt;/p&gt;

&lt;p&gt;It should simply discover:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;another handler created the same logical subscription first&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and continue from the state that now exists.&lt;/p&gt;

&lt;p&gt;In simplified form:&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;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_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;subscription&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin_nested&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;IntegrityError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production code has more state handling around this, but that is the important idea.&lt;/p&gt;

&lt;p&gt;The unique constraint becomes part of the concurrency mechanism rather than merely an exception waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I made the agent prove the fix
&lt;/h2&gt;

&lt;p&gt;This is the part I now care about most.&lt;/p&gt;

&lt;p&gt;After changing the code and adding the regression test, the task was still not finished.&lt;/p&gt;

&lt;p&gt;Claude redeployed the staging environment and ran another actual sandbox payment.&lt;/p&gt;

&lt;p&gt;This time the webhook events arrived in the opposite order.&lt;/p&gt;

&lt;p&gt;That was useful.&lt;/p&gt;

&lt;p&gt;The fix was no longer depending on the ordering we happened to see during the first run.&lt;/p&gt;

&lt;p&gt;The resulting subscription state was correct and credits were granted exactly once.&lt;/p&gt;

&lt;p&gt;I then replayed the real webhook notification and checked that the state and credit ledger remained unchanged.&lt;/p&gt;

&lt;p&gt;Only after that did I consider the billing path proven.&lt;/p&gt;

&lt;p&gt;The temporary Render infrastructure was deleted afterward, along with the temporary Paddle webhook destination. Cleaning up the receiver without cleaning up the sender would have left Paddle retrying a dead endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  “The API returned 200” is not the success condition
&lt;/h2&gt;

&lt;p&gt;A comment on a Reddit discussion about this gave another good example.&lt;/p&gt;

&lt;p&gt;Someone described an agent-run publishing pipeline that considered a post successful because the publishing API returned &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But sometimes the published content had been silently truncated.&lt;/p&gt;

&lt;p&gt;The fix was to make the agent fetch the published item back and diff it against the source.&lt;/p&gt;

&lt;p&gt;That is the same pattern.&lt;/p&gt;

&lt;p&gt;These are different claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the action completed without error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the intended state now exists in the real system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is what we usually care about.&lt;/p&gt;

&lt;p&gt;Yet a lot of automation, including agentic automation, stops at the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coding agents need falsifiable completion conditions
&lt;/h2&gt;

&lt;p&gt;This changed the way I think about using coding agents.&lt;/p&gt;

&lt;p&gt;A prompt like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Implement Paddle billing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;is useful.&lt;/p&gt;

&lt;p&gt;A prompt like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Implement Paddle billing and write tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;is better.&lt;/p&gt;

&lt;p&gt;But the much more interesting version is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here is the external condition that would prove billing actually works. Keep going until you can demonstrate it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That produces a different workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;implement
    ↓
deploy
    ↓
interact with the real system
    ↓
inspect resulting state
    ↓
try to falsify the assumptions
    ↓
fix
    ↓
repeat the proof
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent is no longer just responsible for producing code.&lt;/p&gt;

&lt;p&gt;It is responsible for producing evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  I started applying this elsewhere
&lt;/h2&gt;

&lt;p&gt;Once I started thinking this way, I found the same problem in several other parts of the release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run the setup guide from a truly fresh clone
&lt;/h3&gt;

&lt;p&gt;A setup procedure can work perfectly on the developer's machine because the shell already contains environment variables, dependencies or state that the documentation forgot to mention.&lt;/p&gt;

&lt;p&gt;So don't ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Are the getting-started docs correct?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Make the agent actually start from a clean clone and execute them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clear ambient environment variables
&lt;/h3&gt;

&lt;p&gt;One of our setup paths appeared reproducible until we removed configuration inherited from the agent's shell.&lt;/p&gt;

&lt;p&gt;The environment had been silently helping the test pass.&lt;/p&gt;

&lt;p&gt;The interesting test wasn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;does setup work here?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;does setup work with none of our accidental local state?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ask the database whether migrations match the models
&lt;/h3&gt;

&lt;p&gt;The application tests were green.&lt;/p&gt;

&lt;p&gt;A fresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alembic check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;reported 174 schema operations.&lt;/p&gt;

&lt;p&gt;Some were metadata details rather than catastrophic defects, but the important point was that “tests pass” and “the deployed schema matches the declared model” were separate claims.&lt;/p&gt;

&lt;p&gt;So we tested the second one directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prove regression tests by restoring the bug
&lt;/h3&gt;

&lt;p&gt;An agent can write a regression test that passes without really protecting against the original failure.&lt;/p&gt;

&lt;p&gt;A simple way to challenge that is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;apply the fix;&lt;/li&gt;
&lt;li&gt;confirm the new test passes;&lt;/li&gt;
&lt;li&gt;temporarily restore the old broken behavior;&lt;/li&gt;
&lt;li&gt;confirm that the new test fails;&lt;/li&gt;
&lt;li&gt;restore the fix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives you evidence that the test is detecting the actual regression rather than merely exercising nearby code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents are good at closing loops
&lt;/h2&gt;

&lt;p&gt;There is a lot of attention around how many lines of code an AI agent can write.&lt;/p&gt;

&lt;p&gt;I'm finding the more valuable property is something else.&lt;/p&gt;

&lt;p&gt;They can stay inside verification loops that are tedious enough that humans often stop early.&lt;/p&gt;

&lt;p&gt;Provision the environment.&lt;/p&gt;

&lt;p&gt;Deploy.&lt;/p&gt;

&lt;p&gt;Exercise the system.&lt;/p&gt;

&lt;p&gt;Inspect the database.&lt;/p&gt;

&lt;p&gt;Read webhook deliveries.&lt;/p&gt;

&lt;p&gt;Replay the event.&lt;/p&gt;

&lt;p&gt;Change the implementation.&lt;/p&gt;

&lt;p&gt;Deploy again.&lt;/p&gt;

&lt;p&gt;Repeat the transaction.&lt;/p&gt;

&lt;p&gt;Compare the result.&lt;/p&gt;

&lt;p&gt;Clean everything up.&lt;/p&gt;

&lt;p&gt;None of those individual steps is especially impressive.&lt;/p&gt;

&lt;p&gt;Doing the entire loop without deciding that “probably fine” is good enough is where the value starts to appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  This came out of building The Fabrica
&lt;/h2&gt;

&lt;p&gt;All of this happened while preparing the first release of &lt;strong&gt;The Fabrica&lt;/strong&gt;, a production FastAPI + Next.js SaaS foundation I built after getting tired of rebuilding the same infrastructure for every product.&lt;/p&gt;

&lt;p&gt;It includes the parts that tend to consume the beginning of a SaaS project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;authentication;&lt;/li&gt;
&lt;li&gt;Paddle subscriptions;&lt;/li&gt;
&lt;li&gt;credit accounting;&lt;/li&gt;
&lt;li&gt;background workers;&lt;/li&gt;
&lt;li&gt;database migrations;&lt;/li&gt;
&lt;li&gt;lifecycle and deletion handling;&lt;/li&gt;
&lt;li&gt;production-oriented setup and deployment patterns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn't that those components are difficult to generate.&lt;/p&gt;

&lt;p&gt;Coding agents can generate most of them very quickly.&lt;/p&gt;

&lt;p&gt;The difficult part is knowing whether all of those components still behave correctly once they interact with each other and with real external systems.&lt;/p&gt;

&lt;p&gt;That is what I wanted to package.&lt;/p&gt;

&lt;p&gt;The first release is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.thefabrica.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=fabrica_launch_010&amp;amp;utm_content=dev_billing_e2e" rel="noopener noreferrer"&gt;https://www.thefabrica.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=fabrica_launch_010&amp;amp;utm_content=dev_billing_e2e&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the main thing I took from the release process is this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't only ask your coding agent to build the thing. Give it a condition that reality can prove false.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>fastapi</category>
      <category>saas</category>
    </item>
    <item>
      <title>credits_remaining -= 1 is a race condition</title>
      <dc:creator>Indie Rob</dc:creator>
      <pubDate>Thu, 03 Sep 2026 21:49:01 +0000</pubDate>
      <link>https://dev.to/indierob_/creditsremaining-1-is-a-race-condition-52fo</link>
      <guid>https://dev.to/indierob_/creditsremaining-1-is-a-race-condition-52fo</guid>
      <description>&lt;p&gt;A &lt;code&gt;credits_remaining: int&lt;/code&gt; column on your users table looks perfectly fine for an AI SaaS. Sign a user up, drop 100 credits into their row, deduct on each request, top up on payment. Done.&lt;/p&gt;

&lt;p&gt;It stays fine until the work you're charging for goes asynchronous. Then it becomes surprisingly easy to charge twice, overspend, or lose track of why a balance changed.&lt;/p&gt;

&lt;p&gt;I built it the naive way first. Here is the failure mode I ran into, and the pattern I ended up shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race
&lt;/h2&gt;

&lt;p&gt;Imagine a user has 10 credits. Two API requests arrive almost simultaneously, each costing 7 credits' worth of compute.&lt;/p&gt;

&lt;p&gt;Both handlers read the balance:&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;balance&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_val&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT credits_remaining FROM users WHERE id = :id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both check &lt;code&gt;10 &amp;gt;= 7&lt;/code&gt;. Both decide the user can afford the job. Both enqueue the work.&lt;/p&gt;

&lt;p&gt;You just sold 14 credits of compute for 10.&lt;/p&gt;

&lt;p&gt;If your compute costs real money — a hosted LLM call, a GPU minute, a paid third-party API — you are eating the delta. If it costs enough, that is a real leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But I'll wrap it in a transaction"
&lt;/h2&gt;

&lt;p&gt;Common first fix. Doesn't do what most people think.&lt;/p&gt;

&lt;p&gt;Postgres defaults to &lt;code&gt;READ COMMITTED&lt;/code&gt; isolation. Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, two concurrent transactions can each see the pre-spend balance before either commits. Both read 10, both decide 7 is affordable, both write back a new balance of 3 — the second overwriting the first. Net effect: same overspend, and you have thrown away one of the deductions on top of it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SERIALIZABLE&lt;/code&gt; isolation would catch this. It also turns every write in your application into something that might fail with a serialization error and need a retry loop, whether it has anything to do with the billing race or not. Most teams don't want that as a global setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The narrower fix: lock the row for the spend
&lt;/h2&gt;

&lt;p&gt;The tighter answer is to serialize only the read-and-write for the balance being spent from.&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;with&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;transaction&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="k"&gt;await&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;fetch_val&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT credits_remaining FROM users WHERE id = :id FOR UPDATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InsufficientCredits&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE users SET credits_remaining = credits_remaining - :cost &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE id = :id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cost&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;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; acquires a row-level lock. The second concurrent transaction blocks until the first commits, then sees the updated balance of 3 and correctly refuses the second 7-credit job.&lt;/p&gt;

&lt;p&gt;That closes the race. Two different jobs can no longer overspend the same balance.&lt;/p&gt;

&lt;p&gt;Another problem shows up almost immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second race: retries
&lt;/h2&gt;

&lt;p&gt;Workers retry. Brokers redeliver. HTTP clients retry on &lt;code&gt;504&lt;/code&gt;. Users double-click. If the same logical job can enter your deduction path twice, your billing correctness now depends on every caller in the system behaving perfectly and never retrying a completed request.&lt;/p&gt;

&lt;p&gt;That is a bad invariant to bet a billing system on.&lt;/p&gt;

&lt;p&gt;The row lock doesn't help here. Each retry is its own transaction. It dutifully takes the lock, sees the current balance, and deducts again.&lt;/p&gt;

&lt;p&gt;The fix is separate: an &lt;strong&gt;idempotency key&lt;/strong&gt; at the deduction layer. Give every logical job a stable ID, and make the deduction insert unique on &lt;code&gt;(user_id, job_id, kind)&lt;/code&gt;. A retry that tries to re-insert the same fact gets rejected by the unique constraint, and the handler treats that rejection as "already recorded, carry on".&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction I originally missed
&lt;/h2&gt;

&lt;p&gt;Row lock and idempotency key are both about "the same fact shouldn't be charged twice", but they solve different races:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row lock&lt;/strong&gt; prevents &lt;em&gt;two different jobs&lt;/em&gt; from overspending the same balance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency key&lt;/strong&gt; prevents &lt;em&gt;the same job&lt;/em&gt; from being charged twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I originally treated them as one problem. They aren't. You need both, and they compose cleanly in the same handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern I ended up shipping: an append-only ledger
&lt;/h2&gt;

&lt;p&gt;Once I accepted that "the balance" is derived state, not primary state, the schema simplified.&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="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;delta&lt;/span&gt;       &lt;span class="nb"&gt;INTEGER&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;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="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="n"&gt;reason&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&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="k"&gt;UNIQUE&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;job_id&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="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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every movement is a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grant           +100
deduct            -7
task_started       0
task_completed     0
refund            +7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The balance is &lt;code&gt;SUM(delta) WHERE user_id = ?&lt;/code&gt;. Never stored, always computed. If you're worried about the read cost, cache it — but the ledger stays the source of truth.&lt;/p&gt;

&lt;p&gt;Spending looks like this:&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;with&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;transaction&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="k"&gt;await&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;fetch_val&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        SELECT COALESCE(SUM(delta), 0)
        FROM credit_entries
        WHERE user_id = :id
        FOR UPDATE
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InsufficientCredits&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO credit_entries
                (user_id, delta, kind, job_id, reason)
            VALUES
                (:id, :delta, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deduct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, :job_id, :reason)
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&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;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_id&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reason&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;except&lt;/span&gt; &lt;span class="n"&gt;UniqueViolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# (user, job, 'deduct') already exists. Retry is a no-op.
&lt;/span&gt;        &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Row lock handles the concurrent-jobs race. Unique constraint handles the retry race. Same handler. Both problems addressed, no special cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ledger earns its weight
&lt;/h2&gt;

&lt;p&gt;More machinery than a single integer column, obviously. It earns it in ways you feel every week once you have real users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refunds are new facts, not mutations.&lt;/strong&gt; A failed job gets a &lt;code&gt;+7 refund&lt;/code&gt; entry that references the original deduction. The original deduction stays. Nothing was rewritten. You can look back at the sequence and see exactly what happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failed jobs remain explainable.&lt;/strong&gt; The &lt;code&gt;task_started&lt;/code&gt; and &lt;code&gt;task_completed&lt;/code&gt; entries mean you can tell the difference between "we charged the user and the job succeeded" and "we charged the user and something died".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconciliation becomes possible.&lt;/strong&gt; If workers crash between deducting and completing, you can find orphaned deductions programmatically — &lt;code&gt;deduct&lt;/code&gt; entries without a matching &lt;code&gt;task_completed&lt;/code&gt; or &lt;code&gt;refund&lt;/code&gt; after some timeout — and repair them without opening the database by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support can answer "why is my balance 37?"&lt;/strong&gt; They run one query and read the last N rows in chronological order. This is a real support cost you're paying whether you notice it or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Billing state has an audit trail by default&lt;/strong&gt;, without you having to bolt on a separate audit log later.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it is overkill
&lt;/h2&gt;

&lt;p&gt;If your credits map 1:1 to synchronous API calls, no async work, no retries, no long-running compute, then no — you don't need this. A locked update on a column is fine.&lt;/p&gt;

&lt;p&gt;The ledger earns its complexity when at least one of these is true: work happens off the request path, workers can retry, refunds are non-trivial, or support has to answer questions about historical balances.&lt;/p&gt;

&lt;p&gt;If you're building an AI SaaS with billed LLM calls, all four are usually true from month one.&lt;/p&gt;

&lt;h2&gt;
  
  
  In production
&lt;/h2&gt;

&lt;p&gt;I put the decision doc, a concurrency test receipt (real PostgreSQL sessions, two concurrent deducts of 7 on a starting balance of 10, asserting exactly one succeeds and the balance ends at 3), and a few other engineering excerpts into a public inspection repo for &lt;a href="https://thefabrica.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=credit_ledger" rel="noopener noreferrer"&gt;The Fabrica&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/webrot9/thefabrica-inspect" rel="noopener noreferrer"&gt;github.com/webrot9/thefabrica-inspect&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not the commercial source. Enough code and enough decisions to inspect whether the engineering claims hold up before deciding to license the full codebase. The ledger is one of the pieces I most wanted to expose there, because it's exactly the kind of thing that separates a starter kit from a codebase you can actually run a business on.&lt;/p&gt;

&lt;p&gt;If you're building anything with async, billed work, do the ledger from day one. Retrofitting it after your first "sold 14 for 10" incident is not fun.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>python</category>
      <category>saas</category>
      <category>concurrency</category>
    </item>
  </channel>
</rss>
