Skip to Content
Credits & BillingCredit Tracking

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_reserved

This 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:17

Note: 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 allocation

API Endpoints

Dashboard (tenant-facing)

GET /api/dashboard/credits

Returns 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=consumed

Paginated 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=6

Monthly 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/topup

Initiates 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=xxx

Full credit position for any tenant (super admin).

POST /api/admin/credits/adjust

Manual 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 thresholdWhat happens
≤ 20% remainingYellow warning banner in Dashboard: “You have X credits remaining this month.”
≤ 10% remainingOrange warning banner: “You’re almost out of credits. Buy top-up or wait for reset on [date].“
0 availableRed banner: “Credit limit reached. New activities are paused.” + count of blocked activities
0 available + blocked jobsEmail 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 endpoints

© 2026 Leadmetrics — Internal use only