VicroCode
Make Code Create Value
VicroCode is a lightweight online platform for publishing, running, sharing, and monetizing code projects. Launch HTML, Python, SQLite, AI agents, management tools, games, and more without server setup.
Please wait while VicroCode loads. You can also explore the AI programming guide
Loading...

AI MARKET GUIDE

When a Credit Balance Drops for No Reason, Build a Ledger You Can Actually Read

A learning app drained its energy meter wrong, then silently refunded gems. The fix for any metered free tier: append-only debits and refunds on an inspectable ledger.

Someone posted about opening their language-learning app, answering five questions, and getting hit with a "no energy" screen. Free users are supposed to get 25 energy a day, roughly one point per question, enough for a full lesson. Instead the meter emptied early. They bought energy with gems, watched the balance fill to full, and then it snapped straight to 0 before they could even get back to the question screen. The gems they spent got refunded on their own. Their read: a bug, or maybe a network hiccup.

I don't know the internal cause, and neither does the person who filed it. Whether it was a race condition, a stale cache, or two writes stepping on each other is unverified. But the shape of the complaint is the interesting part, and it's the same shape you see everywhere metered consumption shows up right now.

The real signal: metering is everywhere, and it's opaque

Look across what people are actually arguing about. AI subscription tiers getting re-cut from 20x to 10x weekly quotas. Coding assistants where users want to see cost, tokens, and cache hits per request because they've been burned by black-box billing. A local gateway project whose headline selling point is literally "every request is visible" — which rule it matched, which upstreams it tried, what it cost. The energy-meter story is the consumer-app version of the same anxiety: a balance moved, the user can't explain why, and the system offers no way to check its own work.

That's the reusable lesson for anyone shipping a metered free tier. The failure isn't that a balance changed. Balances change. The failure is that the balance is a single silently mutating number. When it jumps to full and back to zero in a blink, there's nothing to point at. No operator can answer "what happened to my energy" and no user can verify the answer.

Stop storing a balance. Store the transactions.

The move is boring and it works: make the balance a derived value, not a stored one. Every debit and every refund becomes an append-only row in a ledger. Nothing overwrites. Nothing gets mutated in place. The current balance is just the sum of the rows.

A row needs enough to reconstruct the story later:

  • a timestamp
  • the user or account it belongs to
  • a signed delta (negative for a debit, positive for a refund or top-up)
  • a reason string (`answer_question`, `purchase`, `auto_refund`, `daily_reset`)
  • a request id so you can tell a retry from a genuine second charge
  • the balance after this row was applied, for a cheap sanity check

Now the exact scenario from the complaint is legible. If gems drained and refunded, you'd see a debit row and a matching refund row with the same request id, seconds apart. If a race doubled a charge, you'd see two debits with the same request id, which is precisely the thing a `UNIQUE(request_id)` constraint would have refused. The pathology stops being a mystery and becomes a query.

Building it on VicroCode, end to end

Here's how I'd stand this up as a small working system without spinning up any infrastructure.

The ledger itself goes in SQLite. One table, append-only by convention, with the unique constraint on request id doing the heavy lifting against double-writes. The nice part is you can open it directly with the SQLite editor and read the exact rows a user is disputing, in order, without writing a debug tool first. That inspectability is the whole point — the operator gets the same view the code does.

CREATE TABLE ledger (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  delta INTEGER NOT NULL,
  reason TEXT NOT NULL,
  request_id TEXT NOT NULL,
  balance_after INTEGER NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(user_id, request_id)
);

The deduction logic is a small endpoint you run Python online. It does one job carefully: read the current balance, check the request id hasn't been seen, reject the debit if funds are short, then insert the new row inside a single transaction. Keeping it small matters. The moment deduction lives in three places, they drift and you're back to guessing.

def apply_change(conn, user_id, delta, reason, request_id):
    cur = conn.cursor()
    try:
        row = cur.execute(
            "SELECT balance_after FROM ledger WHERE user_id=? "
            "ORDER BY id DESC LIMIT 1", (user_id,)
        ).fetchone()
        balance = row[0] if row else 0
        new_balance = balance + delta
        if new_balance < 0:
            return {"ok": False, "reason": "insufficient", "balance": balance}
        cur.execute(
            "INSERT INTO ledger (user_id, delta, reason, request_id, balance_after) "
            "VALUES (?,?,?,?,?)",
            (user_id, delta, reason, request_id, new_balance),
        )
        conn.commit()
        return {"ok": True, "balance": new_balance}
    except sqlite3.IntegrityError:
        # duplicate request_id: the charge already happened, don't repeat it
        conn.rollback()
        row = cur.execute(
            "SELECT balance_after FROM ledger WHERE user_id=? "
            "ORDER BY id DESC LIMIT 1", (user_id,)
        ).fetchone()
        return {"ok": True, "balance": row[0], "idempotent": True}

The idempotent retry path is the quiet hero here. A flaky network — exactly what the original poster wondered about — sends the same debit twice. Without the unique constraint you charge twice and later refund once, and now the ledger tells a confusing story. With it, the second call returns the same balance and adds no row.

The user-facing balance view is a page you run HTML online, calling that endpoint and rendering both the current number and the recent transaction rows underneath it. Showing the last ten debits and refunds next to the balance turns a support ticket into a self-service answer. The user sees the debit, sees the refund, sees the timestamps, and stops feeling like the app is gaslighting them.

Trade-offs I'd actually flag

A couple of honest caveats before you copy this into production.

SQLite handles a single-writer workload fine, and a hosted deduction endpoint serializing writes fits that model well. If you genuinely need many concurrent writers hammering one account, you'll want to think harder about locking, but a metered free tier for one user at a time is comfortably inside what this does well.

The bigger one: the endpoint above has no authentication. As written, anyone who learns the URL can post debits and refunds for any `user_id`. That's fine for a prototype you're poking at, and unacceptable the moment real balances ride on it. Before this goes anywhere public, put an auth check in front of the endpoint so a caller can only move its own balance. I'm calling that out specifically because it's the kind of gap that's easy to ship and painful to discover later.

Anything past that — a mobile client, a different database engine, syncing to some external billing provider — falls outside what I'd claim here. The scope that holds up is the one described: an inspectable SQLite ledger, a small Python deduction endpoint, and a hosted balance view. That's enough to make a suspicious balance drop explainable instead of a shrug.