What you will build

A billing agent that tracks customer subscriptions, generates a fresh USDC invoice on each renewal date, escalates polite reminders when payment is late, pauses access after a dunning window, handles cancellations with optional pro-rated refunds, and exposes an MRR line that updates every night. The agent runs as a small Node service with a cron-driven worker — no third-party billing platform, no webhook forests, no subscription-management SaaS bill.

This pattern is purpose-built for AI agent products: per-seat monthly plans for agent teams, metered plans for token-based services, annual plans for enterprise buyers. All the accounting happens in USDC on Polygon or Base with final settlement in seconds.

Prerequisites

Step 1 — Subscription data model

Subscriptions are boring and boring is good. Small schema, explicit state machine, human-readable intervals.

-- subscriptions.sql
CREATE TABLE subscriptions (
  id                 TEXT PRIMARY KEY,       -- sub_01...
  customer_id        TEXT NOT NULL,
  customer_wallet    TEXT NOT NULL,          -- MoltPe agent wallet address
  plan_id            TEXT NOT NULL,          -- e.g. 'pro-monthly'
  amount_usdc        TEXT NOT NULL,
  interval_months   INTEGER NOT NULL,        -- 1 or 12
  status             TEXT NOT NULL,          -- active | paused | cancelled
  dunning_state      TEXT,                   -- null | reminder_1 | reminder_2 | paused
  next_renewal       TEXT NOT NULL,          -- ISO date
  last_paid_at       TEXT,
  created_at         TEXT NOT NULL
);

Step 2 — Nightly renewal worker

The worker runs once per day. It finds subscriptions due for renewal, creates an invoice tied to each, and advances next_renewal when the invoice lands paid (via webhook). If the invoice does not land within the window, the dunning worker picks it up.

// renewal-worker.js — run daily via cron.
import Database from "better-sqlite3";
import { ulid } from "ulid";

const db = new Database("subscriptions.db");
const RECEIVING_AGENT = "ag_01JMWBE2A3C4D5E6F7G8H9J0KL";

export async function runRenewals() {
  const due = db.prepare(`SELECT * FROM subscriptions
    WHERE status='active' AND next_renewal <= date('now')`).all();

  for (const sub of due) {
    // 1. Create a MoltPe invoice for this renewal.
    const inv = await fetch("https://api.moltpe.com/v1/invoices/create", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.MOLTPE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        agent_id: RECEIVING_AGENT,
        amount: sub.amount_usdc,
        token: "USDC",
        network: "polygon",
        due_date: new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10),
        external_id: `renewal_${sub.id}_${ulid()}`,
        metadata: { subscription_id: sub.id, type: "renewal" },
      }),
    }).then(r => r.json());

    // 2. Try to auto-charge via the customer's agent wallet (pull-style).
    const charge = await fetch("https://api.moltpe.com/v1/payments/request", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.MOLTPE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from_wallet: sub.customer_wallet,
        to_agent_id: RECEIVING_AGENT,
        amount: sub.amount_usdc,
        memo: inv.memo,
      }),
    }).then(r => r.json());

    if (charge.status === "settled") {
      const nextRenewal = new Date();
      nextRenewal.setMonth(nextRenewal.getMonth() + sub.interval_months);
      db.prepare(`UPDATE subscriptions SET
        last_paid_at=datetime('now'),
        next_renewal=?, dunning_state=NULL
        WHERE id=?`).run(nextRenewal.toISOString().slice(0, 10), sub.id);
    }
  }
}

Step 3 — Dunning for failed renewals

If the customer's wallet has insufficient balance or the auto-charge fails, MoltPe returns a typed error and you enter dunning. The pattern is three escalating reminders over two weeks, then pause access.

// dunning-worker.js — runs daily, escalates unpaid invoices.
const stale = db.prepare(`SELECT s.*, i.created_at AS inv_created FROM subscriptions s
  JOIN invoices i ON i.metadata_subscription_id = s.id
  WHERE i.status = 'open' AND s.status = 'active'`).all();

for (const row of stale) {
  const ageDays = Math.floor((Date.now() - Date.parse(row.inv_created)) / 86400000);
  let nextState = row.dunning_state;

  if (ageDays >= 3 && !row.dunning_state) nextState = "reminder_1";
  else if (ageDays >= 7 && row.dunning_state === "reminder_1") nextState = "reminder_2";
  else if (ageDays >= 14 && row.dunning_state === "reminder_2") nextState = "paused";

  if (nextState !== row.dunning_state) {
    if (nextState === "paused") {
      db.prepare(`UPDATE subscriptions SET status='paused', dunning_state='paused' WHERE id=?`).run(row.id);
      await sendEmail(row.customer_id, "Your subscription is paused", "Please complete payment to restore access.");
    } else {
      db.prepare(`UPDATE subscriptions SET dunning_state=? WHERE id=?`).run(nextState, row.id);
      await sendEmail(row.customer_id, "Payment reminder", `Your renewal of ${row.amount_usdc} USDC is still open.`);
    }
  }
}

Step 4 — Cancellations and refunds

Cancellations flip status to cancelled and skip the next renewal run. If the customer wants a pro-rated refund, compute it simply — no fancy math needed.

app.post("/subscriptions/:id/cancel", async (req, res) => {
  const sub = db.prepare(`SELECT * FROM subscriptions WHERE id=?`).get(req.params.id);
  if (!sub) return res.status(404).send("not found");

  db.prepare(`UPDATE subscriptions SET status='cancelled' WHERE id=?`).run(sub.id);

  // Optional pro-rated refund if last payment was within current period.
  if (req.body.refund && sub.last_paid_at) {
    const periodDays = sub.interval_months * 30;
    const usedDays = Math.floor((Date.now() - Date.parse(sub.last_paid_at)) / 86400000);
    const remaining = Math.max(0, periodDays - usedDays);
    const refundAmount = ((parseFloat(sub.amount_usdc) * remaining) / periodDays).toFixed(2);

    if (parseFloat(refundAmount) > 0) {
      await fetch("https://api.moltpe.com/v1/payments/refund", {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${process.env.MOLTPE_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ subscription_id: sub.id, amount: refundAmount, reason: "cancellation" }),
      });
    }
  }
  res.json({ ok: true });
});

Step 5 — MRR dashboard query

MRR is one SQL line. Normalize yearly plans to monthly, sum across active subscriptions. Render it on a dashboard and you are good for the next investor update.

-- MRR (monthly recurring revenue) in USDC.
SELECT ROUND(SUM(CAST(amount_usdc AS REAL) / interval_months), 2) AS mrr_usdc
FROM subscriptions
WHERE status = 'active';

Testing the agent

Create two test subscriptions — one monthly, one yearly — with funded customer wallets. Set next_renewal to today and run the worker manually. Confirm both renew, the MRR query adds up correctly, and next_renewal advances properly (one month vs twelve).

node --experimental-modules -e "import('./renewal-worker.js').then(m => m.runRenewals())"

Then intentionally drain one customer wallet below the renewal amount and re-run. The renewal should fail, the invoice stays open, and after three simulated days the dunning worker sends reminder 1. Fast-forward by editing the invoice's created_at timestamp in the DB if needed — the state machine should flow reminder_1 → reminder_2 → paused at the right boundaries.

Production checklist