<?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: Snap AI</title>
    <description>The latest articles on DEV Community by Snap AI (@snapaistudio).</description>
    <link>https://dev.to/snapaistudio</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%2F4153721%2F5f872ea8-cf51-4e1a-92b5-ca6d876c7359.png</url>
      <title>DEV Community: Snap AI</title>
      <link>https://dev.to/snapaistudio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/snapaistudio"/>
    <language>en</language>
    <item>
      <title>How we put 41 AI image and video models behind one credit ledger</title>
      <dc:creator>Snap AI</dc:creator>
      <pubDate>Thu, 01 Oct 2026 06:12:24 +0000</pubDate>
      <link>https://dev.to/snapaistudio/how-we-put-41-ai-image-and-video-models-behind-one-credit-ledger-502l</link>
      <guid>https://dev.to/snapaistudio/how-we-put-41-ai-image-and-video-models-behind-one-credit-ledger-502l</guid>
      <description>&lt;p&gt;Calling an image or video model is the easy part of a multi-model AI app. Every gateway has a decent SDK and the happy path takes an afternoon. The parts that took real thought in &lt;a href="https://snapaistudio.com" rel="noopener noreferrer"&gt;Snap AI Studio&lt;/a&gt;, a web studio that puts 41 image, video and audio models on one credit balance, were money and state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;take the credits before a job runs, without letting two tabs spend the same balance&lt;/li&gt;
&lt;li&gt;give them back exactly once when a provider fails, no matter how many code paths notice the failure&lt;/li&gt;
&lt;li&gt;finish jobs that nobody is polling, because the user closed the tab&lt;/li&gt;
&lt;li&gt;make Stripe webhooks safe to replay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is how each of those works in a Next.js 16 app with Prisma 7 and Postgres. Nothing here is specific to AI; any app that sells metered work to a third party has the same four problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  One interface, several gateways
&lt;/h2&gt;

&lt;p&gt;Each model in the registry declares which gateways can serve it. OpenRouter handles most image and video models; fal covers what OpenRouter does not expose (lip sync, text to speech, music, upscaling, background removal, face swap). A mock provider implements the same interface and returns placeholder images and sample clips, so the whole app runs with zero API keys. That last part matters more than it sounds: every refund path below can be tested locally by putting a magic word in the prompt that makes the mock fail on purpose.&lt;/p&gt;

&lt;p&gt;For a job, the gateway named by &lt;code&gt;MODEL_PROVIDER&lt;/code&gt; is tried first, then any other gateway that has a key and a verified id for that model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debit first, in one transaction
&lt;/h2&gt;

&lt;p&gt;A job row and the debit are created in the same transaction, before anything is sent to a provider. The debit is a conditional update, so the balance check and the decrement are one statement:&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;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&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="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decrement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&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="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;creditLedger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&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="na"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;balanceAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two requests racing for the last credits cannot both pass, because Postgres evaluates &lt;code&gt;credits &amp;gt;= amount&lt;/code&gt; and applies the decrement atomically for each row update. Every movement also writes a ledger row with the balance after it, so the balance can always be explained line by line.&lt;/p&gt;

&lt;p&gt;Only after the transaction commits does the app call &lt;code&gt;provider.submit&lt;/code&gt;. If submit throws, the job is failed and refunded through the same path as any other failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refund exactly once
&lt;/h2&gt;

&lt;p&gt;Failures arrive from several places: the submit call, the request that polls the job, the background sweeper, a timeout. Any two of them can notice the same failure at the same moment. The fix is to make "fail this job" a claim that only one caller can win:&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;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&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="na"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;running&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;finishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;grantCredits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&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="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;job_refund&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Refund for failed generation&lt;/span&gt;&lt;span class="dl"&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;where&lt;/code&gt; clause is the whole trick. The first caller moves the job out of &lt;code&gt;queued&lt;/code&gt;/&lt;code&gt;running&lt;/code&gt; and the refund happens inside the same transaction. Every later caller matches zero rows and returns. No locks, no flags in memory, and it survives a restart halfway through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jobs nobody is watching
&lt;/h2&gt;

&lt;p&gt;The polling endpoint advances a job when the browser asks for it: it calls the provider, copies outputs to storage on success, and refunds on failure. But users close tabs. A background sweeper started from &lt;code&gt;instrumentation.ts&lt;/code&gt; runs every 20 seconds and advances any job that is not terminal. Jobs older than 30 minutes are failed and refunded through the same claim as above, so a provider that never answers cannot hold credits forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync images, async video
&lt;/h2&gt;

&lt;p&gt;OpenRouter's video API is asynchronous: create the job, poll its status, then download the content with the API key. Images are different: the image endpoint is synchronous, and one image can take over a minute. Holding an HTTP request open that long breaks the "submit returns an id" contract the rest of the pipeline relies on, so &lt;code&gt;submit&lt;/code&gt; starts the image request in the background and returns an id at once; the poller reads the result from an in-process map.&lt;/p&gt;

&lt;p&gt;That map is the one place the design assumes a single app instance. If the app ever scales out, image tasks move to a queue. Writing the assumption down next to the code is cheaper than discovering it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Webhooks you can replay
&lt;/h2&gt;

&lt;p&gt;Subscription credits are granted on &lt;code&gt;invoice.paid&lt;/code&gt;, and top-ups on &lt;code&gt;checkout.session.completed&lt;/code&gt;. Two rules keep this safe:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every Stripe event id is stored in the same transaction as the credit grant. A replayed event finds its id already there and does nothing.&lt;/li&gt;
&lt;li&gt;Credits come from the invoice's price id, never from the amount paid. A discounted first month still grants the full plan, and a coupon can never change what a plan is worth.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Safety rejections are failures too
&lt;/h2&gt;

&lt;p&gt;Gateways reject prompts and outputs for safety reasons, and each one says so differently (a 403, a 422 with &lt;code&gt;content_policy_violation&lt;/code&gt;, a flag on the output). The providers map all of them to one failure reason, &lt;code&gt;content_blocked&lt;/code&gt;, which goes through the same refund claim with one difference: those refunds are capped per user per day, so the balance cannot be used to probe a filter for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself on day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the money movement and the state change in the same transaction, always.&lt;/li&gt;
&lt;li&gt;Make every "finish this job" path a conditional update that only one caller can win.&lt;/li&gt;
&lt;li&gt;Build the fake provider first. Every failure path above was tested by typing a word into a prompt box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app this came from is &lt;a href="https://snapaistudio.com" rel="noopener noreferrer"&gt;Snap AI Studio&lt;/a&gt;. The two-photo &lt;a href="https://snapaistudio.com/tools/effects/ai-kiss-video-generator" rel="noopener noreferrer"&gt;AI kiss video tool&lt;/a&gt; is the one with the most moving parts: it runs an image model to put two people in one frame, then a video model on that frame, and both costs are debited up front.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ai</category>
      <category>prisma</category>
      <category>stripe</category>
    </item>
  </channel>
</rss>
