Dev.to AI 🤖 Ai 👁 0 📖 6 min read

Billing for AI video renders: quotes, credit ledgers and automatic refunds

I recently shipped Memevibe, an AI body swap tool: you drop in a short clip, add a photo, and it renders the clip again with you as the person in it. Every render costs real GPU time, takes a few minutes, and can fail ha

I recently shipped Memevibe, an AI body swap tool: you drop in a short clip, add a photo, and it renders the clip again with you as the person in it. Every render costs real GPU time, takes a few minutes, and can fail halfway. That makes billing a more interesting problem than the usual "check subscription, allow request".

This post walks through the billing side: how a price is quoted, how credits are held and settled, and how a failed render gives the credits back without anyone writing to support. The code is simplified TypeScript that shows the shape of the logic, not a copy of production.

The rules the code has to implement

The product rules, as shown to users:

Price depends on tier (Fast, Standard, Ultra), resolution and clip length.
Table prices are per 12 seconds of video. Shorter or longer clips pay their share by the second.
Clips over 15 seconds always run on the Ultra tier.
The Fast tier is not offered at 1080p.
The exact credit cost is shown on the button before the render starts.
A render that fails, or is stopped by the model's safety check, returns its credits automatically.
1. One quote function, used everywhere

The most important decision was having a single pure function that turns a request into a price, and using it in both places that matter: the number on the button and the amount actually charged.

ts
type Tier = "fast" | "standard" | "ultra";
type Resolution = 480 | 720 | 1080;

// Credits per 12 seconds of video
const BASE: Record>> = {
fast: { 480: 8, 720: 17 },
standard: { 480: 10, 720: 20, 1080: 50 },
ultra: { 480: 17, 720: 37, 1080: 93 },
};

const BLOCK_SECONDS = 12;
const ULTRA_ONLY_ABOVE_SECONDS = 15;

interface Quote {
tier: Tier; // the tier that will actually run
resolution: Resolution;
credits: number;
}

function quote(seconds: number, tier: Tier, resolution: Resolution): Quote {
const effectiveTier: Tier =
seconds > ULTRA_ONLY_ABOVE_SECONDS ? "ultra" : tier;

const base = BASE[effectiveTier][resolution];
if (base === undefined) {
throw new Error(${effectiveTier} is not offered at ${resolution}p);
}

const credits = Math.ceil((base * seconds) / BLOCK_SECONDS);
return { tier: effectiveTier, resolution, credits };
}

quote(12, "standard", 480); // 10 credits
quote(6, "standard", 480); // 5 credits
quote(15, "standard", 480); // 13 credits
quote(16, "standard", 480); // ultra, 23 credits

Two details are worth pointing out.

The function returns the effective tier, not just a number. When a 16-second clip is bumped to Ultra, the UI needs to say so. Returning the tier alongside the price means the button can show "Ultra, 23 credits" instead of a number the user can't explain.

The server never trusts the client's number. The browser calls quote() to render the button, but the render endpoint calls it again with the server's own measurement of the clip length. If the two disagree, the request is rejected and the UI refreshes the quote. The price on the button is a promise, so the charge has to come from the same function.

2. A ledger instead of a balance column

It is tempting to store credits_balance on the user row and decrement it. I used an append-only ledger instead: every change to a user's credits is a row, and the balance is the sum.

ts
interface LedgerEntry {
id: string;
userId: string;
delta: number; // +35 for a purchase, -13 for a render
reason: "purchase" | "render_hold" | "render_refund";
jobId?: string;
createdAt: Date;
}

This costs a little at read time and pays for itself the first time someone asks "why do I have 7 credits?". Every answer is a list of rows. It also makes refunds trivial: a refund is not an edit to history, it is a new positive row that points at the same job.

3. Hold on start, settle on finish

A render takes minutes, so the charge can't wait until the end (the user could start ten renders on one credit). Credits are taken when the job is created, inside the same transaction that creates the job:

ts
async function startRender(userId: string, req: RenderRequest) {
const seconds = await measureClipSeconds(req.clipId); // server-side
const q = quote(seconds, req.tier, req.resolution);

return db.transaction(async (tx) => {
const balance = await tx.ledger.sumForUser(userId, { lock: true });
if (balance < q.credits) throw new InsufficientCredits(q.credits, balance);

const job = await tx.jobs.insert({
  userId,
  status: "queued",
  tier: q.tier,
  resolution: q.resolution,
  credits: q.credits,
});

await tx.ledger.insert({
  userId,
  jobId: job.id,
  delta: -q.credits,
  reason: "render_hold",
});

return job;

});
}

Because the job row and the ledger row are written together, there is no state where a job exists without its charge, or a charge exists without a job.

4. The refund path is part of the state machine

A job moves through a small set of states:

queued -> rendering -> completed
-> failed (credits returned)
-> blocked (safety check, credits returned)

The refund is not a support action. It is what the failed and blocked transitions do:

ts
type Outcome = "completed" | "failed" | "blocked";

async function finishJob(jobId: string, outcome: Outcome) {
await db.transaction(async (tx) => {
const job = await tx.jobs.lockById(jobId);

// Callbacks can arrive twice. A finished job stays finished.
if (job.status !== "queued" && job.status !== "rendering") return;

await tx.jobs.update(jobId, { status: outcome });

if (outcome !== "completed") {
  await tx.ledger.insert({
    userId: job.userId,
    jobId,
    delta: +job.credits,
    reason: "render_refund",
  });
}

});
}

The early return is what makes this safe. Model providers retry callbacks, workers crash and restart, and a naive handler would refund the same job twice. Locking the job row and checking its state first means the second call is a no-op. A unique index on (jobId, reason) in the ledger is a cheap second line of defense.

One more case: a job that never reports back. A periodic sweep marks jobs stuck in rendering past a timeout as failed, which sends them down the same refund path. No separate code, no manual cleanup.

A completed render is not refunded, since the compute has been spent. That rule is stated on the pricing page, next to a plain note that AI video output varies between renders.

5. Putting the original sound back

The video model generates frames. The audio the user expects is the original clip's audio, untouched, so the last step of a render is a mux: take the video stream from the generated file and the audio stream from the source clip.

bash
ffmpeg -i swapped.mp4 -i original.mp4 \
-map 0✌️0 -map 1🅰️0 \
-c:v copy -c:a aac -shortest \
output.mp4

Copying the video stream avoids a second encode, and -shortest guards against a generated clip that is a few frames shorter than its source. Keeping the audio out of the generation step entirely is also what keeps the result in sync with the trend.

6. Deleting things on schedule

Uploaded photos and rendered videos are deleted after 30 days. That is a privacy promise on the site, so it is enforced by a scheduled job, not by good intentions: each stored object carries its creation time, and a daily task removes anything past the window along with its database references. The ledger rows stay, because they hold no personal media and are needed for the purchase history.

What I'd do differently, and a question

The part I underestimated was how many paths lead to "the job did not finish": provider errors, safety blocks, timeouts, crashed workers. Modeling them as one transition with one side effect, early on, would have saved me some patching.

The open question I have for anyone who has built per-generation billing: do you hold credits at job start, as above, or authorize and capture at the end? Holding is simpler and prevents overspending, but it means the user's balance drops before they have anything to show for it. I'd like to hear how others handled that.

If you want to see what the product does with all this, there is a short explainer on what AI body swap is and how it differs from face swap.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.