Credit Tracking
How the credit system is stored, queried, and maintained — database tables, API endpoints, monthly reset, and the UI surfaces where credits are visible.
Database Tables
credit_balances
One row per tenant. The live credit position. All fields update atomically via transactions.
CREATE TABLE credit_balances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL UNIQUE REFERENCES tenants(id),
-- Monthly allocation (set by plan; refreshed on billing cycle date)
credits_monthly INTEGER NOT NULL DEFAULT 0,
-- Current period balances
credits_available INTEGER NOT NULL DEFAULT 0, -- allocation + top-ups not yet consumed
credits_reserved INTEGER NOT NULL DEFAULT 0, -- held for in-progress runs
credits_consumed INTEGER NOT NULL DEFAULT 0, -- used this period
-- Top-up pool (drawn first, expires end of billing month)
credits_topup INTEGER NOT NULL DEFAULT 0,
-- Billing cycle
period_start DATE NOT NULL, -- e.g. 2026-04-01
period_end DATE NOT NULL, -- e.g. 2026-04-30
next_reset_at TIMESTAMPTZ NOT NULL, -- 2026-05-01 00:00:00 UTC
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Derived value used throughout the system:
effective_available = credits_available - credits_reservedThis is the balance a new job is checked against before reservation. It is never stored — always computed at query time.
credit_ledger
Immutable append-only log of every credit movement. The ledger is the source of truth — credit_balances is a materialised view of the ledger’s current state.
CREATE TABLE credit_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
-- What caused this entry
type TEXT NOT NULL,
-- 'allocated' Monthly allocation granted (period reset)
-- 'topup' Admin purchased a top-up bundle
-- 'reserved' Credits held for an in-progress activity run
-- 'consumed' Activity run completed successfully
-- 'released' Activity run failed — reserved credits returned
-- 'refunded' Manual admin refund (support action)
-- 'adjusted' Manual balance correction (super admin only)
credits INTEGER NOT NULL,
-- Negative for reserved/consumed, positive for allocated/released/refunded
-- Context
activity_run_id UUID REFERENCES activity_runs(id), -- null for allocation/topup entries
deliverable_type TEXT, -- null for allocation/topup entries
topup_order_id TEXT, -- Razorpay order ID (topup only)
-- Balance snapshot at time of entry
balance_after INTEGER NOT NULL,
-- Audit
actor TEXT, -- 'system' | 'admin:{userId}' | 'support:{userId}'
note TEXT, -- Human note for refund/adjustment entries
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX credit_ledger_tenant_idx ON credit_ledger(tenant_id, created_at DESC);
CREATE INDEX credit_ledger_run_idx ON credit_ledger(activity_run_id);
CREATE INDEX credit_ledger_type_idx ON credit_ledger(tenant_id, type, created_at DESC);Sample ledger entries for ACT-2604-001:
type credits deliverable_type balance_after created_at
─────────── ──────── ──────────────────────── ────────────── ──────────────────────
allocated +100 null 100 2026-04-01 00:00:01
reserved -2 activity-planner 98 2026-04-01 00:00:05
consumed -2 activity-planner 98 2026-04-01 00:02:18
reserved -1 keyword_cluster 97 2026-04-01 00:03:00
consumed -1 keyword_cluster 97 2026-04-01 00:14:22
reserved -1 content_brief 96 2026-04-01 09:38:37
consumed -1 content_brief 96 2026-04-01 09:42:17Note: balance_after is the same for a reserved/consumed pair — the reservation already reduced the visible balance; consumption just reclassifies it from reserved to consumed.
credit_topup_orders
Tracks top-up purchases through Razorpay.
CREATE TABLE credit_topup_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
razorpay_order_id TEXT NOT NULL UNIQUE,
credits INTEGER NOT NULL, -- credits in this bundle
amount_inr INTEGER NOT NULL, -- Razorpay amount in paise
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending' | 'paid' | 'failed' | 'refunded'
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL, -- end of current billing month
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);When Razorpay fires a payment.captured webhook, the order status updates to paid, credits are added to credit_balances.credits_topup and credits_available, and a topup ledger entry is written.
Monthly Reset
On the 1st of every calendar month at 00:00 UTC, a BullMQ cron job resets all credit balances.
// packages/billing/src/credit-reset.ts
async function monthlyCreditsReset(): Promise<void> {
const tenants = await db.query.tenants.findMany({
where: eq(tenants.status, 'active'),
with: { creditBalance: true },
});
for (const tenant of tenants) {
const allocation = PLAN_CREDIT_ALLOCATIONS[tenant.plan];
await db.transaction(async (tx) => {
// 1. Write the new period's allocation ledger entry
await tx.insert(creditLedger).values({
tenantId: tenant.id,
type: 'allocated',
credits: +allocation,
balanceAfter: allocation,
actor: 'system',
note: `Monthly allocation for ${tenant.plan} plan — ${formatMonth(new Date())}`, // e.g. "Monthly allocation for starter plan — April 2026"
createdAt: new Date(),
});
// 2. Reset the balance row
await tx.update(creditBalances)
.set({
creditsMonthly: allocation,
creditsAvailable: allocation,
creditsReserved: 0, // any mid-reset reservations are cleared
creditsConsumed: 0,
creditsTopup: 0, // top-ups expire — unused are forfeited
periodStart: startOfMonth(),
periodEnd: endOfMonth(),
nextResetAt: startOfNextMonth(),
updatedAt: new Date(),
})
.where(eq(creditBalances.tenantId, tenant.id));
});
}
}
const PLAN_CREDIT_ALLOCATIONS: Record<string, number> = {
starter: 100,
professional: 300,
};Note on reservations at reset time:
If any activity runs are in-flight at midnight on the 1st (very unusual but possible for long-running jobs), their reserved credits are cleared by the reset. The runs continue to completion but their credit consumption records show balance_after: 0 for the prior month — a benign accounting edge case.
Plan Change Mid-Month
When a tenant upgrades or downgrades mid-month:
Upgrade (e.g. Free → Pro):
Credits immediately set to the Pro allocation (50)
A partial-month proration applies to the Razorpay subscription charge
Ledger entry: type = 'adjusted', note = 'Plan upgrade Free→Pro'
Downgrade (e.g. Agency → Pro):
Downgrade takes effect at next billing cycle date (not immediate)
Current month's allocation (150) is not reduced
From next month: allocation resets to Pro (50)
Any top-ups purchased this month still count against the Agency allocationAPI Endpoints
Dashboard (tenant-facing)
GET /api/dashboard/creditsReturns the current credit position for the authenticated tenant.
Response:
{
"plan": "starter",
"creditsMonthly": 100,
"creditsAvailable": 100,
"creditsReserved": 3,
"creditsConsumed": 12,
"creditsTopup": 0,
"effectiveAvailable": 97,
"periodStart": "2026-04-01",
"periodEnd": "2026-04-30",
"nextResetAt": "2026-05-01T00:00:00Z",
"percentUsed": 12,
"blockedActivities": 0
}GET /api/dashboard/credits/ledger?page=1&limit=50&type=consumedPaginated credit ledger for the current period. Filterable by type, deliverable_type, date_from, date_to.
Response:
{
"entries": [
{
"id": "ldg_01jwx9abc",
"type": "consumed",
"credits": -1,
"deliverableType": "content_brief",
"activityRef": "ACT-2604-001",
"activityTitle": "Content Brief: AI-Powered Marketing Agencies",
"balanceAfter": 46,
"createdAt": "2026-04-01T09:42:17Z"
}
],
"total": 24,
"page": 1
}GET /api/dashboard/credits/history?months=6Monthly credit consumption summary for the last N months (for the Usage History chart).
Response:
{
"months": [
{ "month": "2026-04", "allocated": 50, "consumed": 12, "topup": 0 },
{ "month": "2026-03", "allocated": 50, "consumed": 44, "topup": 10 },
{ "month": "2026-02", "allocated": 50, "consumed": 38, "topup": 0 }
]
}POST /api/dashboard/credits/topupInitiates a Razorpay order for a top-up bundle.
Body:
{ "bundle": "10cr" }Response:
{
"orderId": "order_abc123",
"amount": 49900,
"currency": "INR",
"razorpayKeyId": "rzp_live_xxx"
}The client completes the Razorpay checkout. On payment.captured webhook: credits applied, ledger entry written, notification sent.
Admin (Manage App)
GET /api/admin/credits?tenantId=xxxFull credit position for any tenant (super admin).
POST /api/admin/credits/adjustManual credit adjustment (support action — refund, correction, promotional grant).
Body:
{
"tenantId": "tenant_leadmetrics",
"credits": 5,
"note": "Goodwill credit — blog post retry failure due to platform issue on 2026-04-07"
}Writes a refunded or adjusted ledger entry and updates credit_balances.
Dashboard UI — Usage Screen
The Dashboard → Usage screen (/usage) surfaces credit data in real time.
┌─────────────────────────────────────────────────────────────────┐
│ Usage — April 2026 │
│ │
│ ████░░░░░░░░░░░░░░░░░░░░ 12% 12 / 100 credits used │
│ │
│ Available: 85 cr (97 available − 3 reserved in progress) │
│ Reserved: 3 cr (2 jobs currently running) │
│ Consumed: 12 cr (this month) │
│ Resets: May 1 │
│ │
│ [Buy Credits] │
│ │
│ ───────────────────────────────────────────────────────────── │
│ Credit Usage by Deliverable Type │
│ │
│ Blog Post ████████░░░░ 4 cr (4 posts × 1 cr each) │
│ Email Newsletter ████░░░░░░░░ 4 cr (2 newsletters) │
│ Google Ads RSA ██░░░░░░░░░░ 2 cr (1 refresh) │
│ Meta Ads ██░░░░░░░░░░ 2 cr (1 refresh) │
│ Other ████░░░░░░░░ ... │
│ │
│ ───────────────────────────────────────────────────────────── │
│ Recent transactions │
│ │
│ Apr 7 Blog Post: AI Marketing Agencies −2 cr 96 rem │
│ Apr 4 Email Newsletter #1 −2 cr 98 rem │
│ Apr 2 Content Brief: AI Marketing Agencies −1 cr 100 rem │
│ Apr 1 Activity Planner (monthly run) −2 cr 98 rem │
│ Apr 1 Allocation granted (Starter plan) +100 cr 100 rem │
│ [View all →] │
└─────────────────────────────────────────────────────────────────┘Warning States
| Balance threshold | What happens |
|---|---|
| ≤ 20% remaining | Yellow warning banner in Dashboard: “You have X credits remaining this month.” |
| ≤ 10% remaining | Orange warning banner: “You’re almost out of credits. Buy top-up or wait for reset on [date].“ |
| 0 available | Red banner: “Credit limit reached. New activities are paused.” + count of blocked activities |
| 0 available + blocked jobs | Email notification sent to tenant admin |
Banners are shown on every Dashboard page (not just the Usage screen) until the balance is replenished.
Reconciliation
The credit_ledger is the source of truth. If credit_balances ever drifts (e.g. due to a failed transaction), it can be fully reconstructed by replaying the ledger:
SELECT
SUM(CASE WHEN type = 'allocated' THEN credits ELSE 0 END) AS total_allocated,
SUM(CASE WHEN type = 'topup' THEN credits ELSE 0 END) AS total_topup,
SUM(CASE WHEN type = 'consumed' THEN credits ELSE 0 END) AS total_consumed,
SUM(CASE WHEN type = 'reserved' THEN credits ELSE 0 END) AS net_reserved,
SUM(CASE WHEN type = 'released' THEN credits ELSE 0 END) AS total_released,
SUM(CASE WHEN type IN ('refunded', 'adjusted') THEN credits ELSE 0 END) AS adjustments
FROM credit_ledger
WHERE tenant_id = :tenantId
AND created_at >= :periodStart
AND created_at < :periodEnd;A nightly reconciliation job runs this query for every tenant and alerts if the derived balance diverges from credit_balances.credits_consumed by more than 0.
Package Location
packages/billing/
├── src/
│ ├── credit-rates.ts # CREDIT_RATES per deliverable type, PLAN_CREDIT_ALLOCATIONS
│ ├── credits.ts # reserveCredits(), consumeCredits(), releaseCredits()
│ ├── credit-reset.ts # monthlyCreditsReset() — BullMQ cron processor
│ ├── credit-topup.ts # initiateTopup(), handleRazorpayWebhook()
│ └── credit-reconcile.ts # nightly reconciliation job
apps/api/src/routes/
├── dashboard/credits.ts # Tenant-facing credit endpoints
└── admin/credits.ts # Super admin credit adjustment endpointsRelated
- Credits Overview — what credits are, plan allocations, top-ups
- Credit Consumption — reservation, retry, rejection, overage policy
- Cost & Usage Tracking — internal LLM USD cost tracking
- PostgreSQL Schema —
credit_balances,credit_ledgertable definitions - API — Dashboard — full endpoint reference