Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Selling 59-cent AI video clips without inventing a currency (a wallet on Cloudflare D1)

Phoenix Motion turns a still image into a five-second video with sound. A 480p clip costs $0.59. That price creates a payments problem. My payment provider, Lemon Squeezy, charges $0.50 plus 5% per transaction. Selling

Phoenix Motion turns a still image into a five-second video with sound. A 480p clip costs $0.59.

That price creates a payments problem. My payment provider, Lemon Squeezy, charges $0.50 plus 5% per transaction. Selling one $0.59 clip per checkout would hand most of it to fees, and nobody wants to go through a checkout for every clip anyway.

The usual answer is credits: sell a "500 credit pack", price clips in credits, let credits expire. I don't like credits. They hide the real price, the packs never divide evenly, and expiring them is a polite way of keeping money for nothing. So the rules I set were:

  • The balance is US dollars, shown as dollars. A clip costs $0.59, not "12 credits".
  • You top up any amount from $5 to $200. No packs.
  • It never expires, and an unused balance is refundable on request.
  • No account and no password.

Here's how that works on Cloudflare Workers with a D1 (SQLite) database.

Identity without accounts

When you first open the app, the browser generates a wallet key, a long random string with a pmk_ prefix. The server only stores its SHA-256 hash. Whoever holds the key holds the balance.

The obvious problem: clear your browser and the balance is gone. The recovery path is the receipt email. Lemon Squeezy lets a checkout carry a custom receipt link, and the top-up puts the wallet key in that link's URL fragment (...#key=pmk_...). Fragments are never sent to a server, so the key doesn't land in anyone's access logs, but clicking the button in the receipt email opens the app with the key back in your browser. No login system, no email service of my own.

Every change is a ledger row

The balance lives in a wallets table, but nothing ever updates it on its own. Every change is a row in wallet_ledger with a kind (topup, hold, release, refund...) and a ref (the order ID, the clip ID), and (kind, ref) is unique.

export async function ledger(env, walletId, delta, kind, ref) {
  try {
    await env.DB.batch([
      env.DB.prepare(
        "INSERT INTO wallet_ledger (wallet_id, delta_cents, kind, ref, created_at) VALUES (?,?,?,?,?)"
      ).bind(walletId, delta, kind, ref, nowIso()),
      env.DB.prepare(
        "UPDATE wallets SET balance_cents = balance_cents + ?, updated_at = ? WHERE id = ?"
      ).bind(delta, nowIso(), walletId),
    ]);
    return true;
  } catch (e) {
    if (String(e).includes("UNIQUE")) return false; // already applied
    throw e;
  }
}

D1's batch() runs as a transaction, so the row and the balance change land together or not at all. The unique constraint makes it idempotent: if Lemon Squeezy delivers the same order_created webhook three times, or a webhook and a polling check race each other, the second insert fails on UNIQUE and the balance moves once. That's the whole defence against double credits and double refunds, and it's enforced by the database rather than by my code remembering to check.

The hard part: two tabs, one dollar

Starting a clip holds its price. The naive version reads the balance, checks it's enough, then subtracts. Open two tabs with $0.60 left, start a clip in each at the same moment, and both reads see $0.60. Both clips start. The balance goes negative.

The fix is to make the check and the write the same statement:

const [row] = await env.DB.batch([
  env.DB.prepare(
    `INSERT INTO wallet_ledger (wallet_id, delta_cents, kind, ref, created_at)
     SELECT ?, ?, 'hold', ?, ? WHERE EXISTS
       (SELECT 1 FROM wallets WHERE id = ? AND locked = 0 AND balance_cents >= ?)`
  ).bind(walletId, -cents, jobId, nowIso(), walletId, cents),
  env.DB.prepare(
    `UPDATE wallets SET balance_cents = balance_cents - ?, updated_at = ?
     WHERE id = ? AND EXISTS
       (SELECT 1 FROM wallet_ledger WHERE kind = 'hold' AND ref = ? AND wallet_id = ?)`
  ).bind(cents, nowIso(), walletId, jobId, walletId),
]);
return Boolean(row.meta.changes);

The ledger row is only written if the balance covers the price, and the balance only moves if that row exists. Inside one transaction, the second tab's insert sees the first tab's deduction and writes nothing. meta.changes tells the caller whether the hold happened. No locks, no read-then-write gap.

When a clip finishes, the hold is kept. When it fails, is refused by the content check, or is cancelled, a release row with the same ref gives it back, and the same unique constraint means it can only be given back once.

Refunds go back through the ledger too

A Lemon Squeezy refund arrives as an order_refunded webhook, possibly partial. It's another ledger row, negative this time, keyed on the refund. Top-ups are checked against what was actually paid (subtotal_usd, with 2% slack for currency rounding), so an underpayment doesn't credit the full amount.

A webhook that never arrives is the failure you don't see, so a scheduled job compares Lemon Squeezy's recent orders against the ledger and emails me when an order was paid but never credited, or refunded but never debited.

What I tested before it took real money

All of this ran locally on wrangler dev first: create a wallet, top up, hold, release on failure, release twice (second one must do nothing), one wallet trying to read another's clips, a bad webhook signature, a duplicate webhook, a partial then a full refund, an underpayment, and a test-mode webhook hitting live mode. Every one of those is a way real money goes wrong.

The result

You top up once, every clip comes off a balance shown in dollars, and the price on the page is the price you pay. The same balance now also pays for AI agents using Phoenix through its MCP server, each with its own spending limit, and it can be topped up in USDC as well as by card. The ledger didn't need to change for either.

If you want to see it from the customer's side, Phoenix Motion is live, and every example clip on the page is a first take, sound included. And if you're running image-to-video on your own hardware instead, I wrote up how we fit Wan 2.2's 14B model on an 8 GB GPU, Radeons included.

📰 Read the original article on Dev.to WebDev

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