Skip to content

Project Credits

Project-level credit budgets limit how much generation a team can run inside a project. Credits are stored on the project, every change is written to a ledger, and the UI can subscribe to balance updates in real time.

This module is intentionally small: configuration lives in credits.constants.ts, business logic in CreditsService, and persistence in MongoDB (projects.credit + creditTransactions).


At a glance

Concept Meaning
allocatedBudget Total credits assigned to the project (null = no finite budget on the project document)
used Credits already consumed by generation debits
remaining max(0, allocatedBudget - used) when a budget exists
Cost Taken from modelConfig.costConfig (same rules as analytics cost usage)
Ledger Append-only creditTransactions rows for allocations, debits, and refunds
No credit field Legacy / unlimited project — skip pre-checks, debits, refunds, and ledger (projectUsesCreditBudget)

Who gets a budget vs bypass (unlimited)

Budget tiers are defined only by RBAC role IDs in CREDIT_DEFAULT_ALLOCATED_BUDGET_BY_ROLE (credits.constants.ts). The user's userRoleIds (or JWT roleIds at request time) are matched against those keys.

flowchart TD
  A[User roleIds] --> B{Any role in<br/>CREDIT_DEFAULT_ALLOCATED_BUDGET_BY_ROLE?}
  B -->|No| C[General default budget]
  B -->|Yes| D[Pick highest matching budget]
  D --> E[Project gets allocatedBudget on create]
  E --> F[Generation debits used]

  C --> E

  subgraph bypassAlso ["Also bypasses credits"]
    I[FEAT_CREDITS_BYPASS permission]
    J[BYPASS_FOR_ROLES e.g. SUPER_ADMIN]
  end
  I --> F
  J --> F
Role ID (example) Default budget on project create
PIONEERS 200
EMPLOYEE 400
Any other non-bypass role CREDIT_DEFAULT_BUDGET_FOR_GENERAL_USERS
Bypass role (SUPER_ADMIN) unlimited (allocatedBudget: null, skips debit)

If a user has both PIONEERS and EMPLOYEE, they receive 1000 (the maximum of the matching values).

To add a new tier: extend CreditBudgetRoleType in credits.types.ts and add an entry to CREDIT_DEFAULT_ALLOCATED_BUDGET_BY_ROLE.


Generation flow (charge on create, refund on failure)

Credits are debited when an asset gen job is created, not when it completes. If the job later fails or is cancelled, the debit is refunded once (idempotent per jobId).

sequenceDiagram
  participant FE as Frontend / API
  participant AG as AssetGenService
  participant CS as CreditsService
  participant DB as MongoDB
  participant Spine as JaduSpine

  FE->>AG: createJob / createJobInitial
  AG->>CS: chargeForAssetGenJob
  alt bypass or no project scope
    CS-->>AG: skip (null)
  else insufficient remaining
    CS-->>FE: CreditsExhaustedError (402)
  else ok
    CS->>DB: atomic $inc credit.used (conditional on remaining >= cost)
    CS->>DB: insert GENERATION transaction
    CS->>Spine: projectCreditBalanceUpdated
  end
  AG->>DB: upsert job (PROCESSING / runJob)

  alt job fails
    AG->>DB: upsert FAILED (or catch in createJob)
    DB->>CS: syncJobCreditsOnUpsert
    CS->>DB: used -= cost, insert REFUND
    CS->>Spine: balance update
  else job completes
    Note over CS: No extra debit (already charged)
  end

Integration points

Location What happens
AssetGenService.createJobInitial chargeForAssetGenJob before first upsert
AssetGenService.createJob chargeForAssetGenJob before runJob; refund in catch if runJob throws
AssetJobModel.upsertJob syncJobCreditsOnUpsert on FAILED / CANCELLED (webhooks, updateJobAsFailed, etc.)
ProjectsService.createProject buildInitialProjectCredit + optional allocation ledger row
ProjectsService.cloneProject Fresh buildInitialProjectCredit for cloner (resets used); allocation ledger row (source: project_clone)
Workbench guru chat (HTTP + socket) assertCanRunGuruChat before agent; chargeAssistantMessageCost on each LLM_RESPONSE
Sketch / z-depth analysis (/workbench/sketchChat) Same LLM cost flow; guruType: sketch_analysis

Idempotency: findGenerationByJobId / findRefundByJobId (per jobId), findGenerationByGuruMessageId (per guru turn), and findGenerationByCreditOperationId (per external op) prevent double charge or double refund.

No project scope: If the job has no projectLinks.projectId and no resolvable storyLinks.storyId → project, credit logic is skipped (no debit).


Cost calculation

calculateModelConfigCost(modelConfig) in credits.utils.ts mirrors analytics:

  1. If costConfig.rules match job inputs → use rule cost (including costPerUnit when configured).
  2. Otherwise → costConfig.defaultCost.
  3. Missing config → 0 (no debit).

Data model

Project (projects.credit)

{
  allocatedBudget: number | null;
  used: number;
}

Ledger (creditTransactions)

{
  transactionId: string;
  project: {
    projectId: string;
    storyId?: string;
    sceneId?: string;
    shotId?: string;
    isTools?: boolean;  // only set when true (job had jobMetaData.projectTools)
  };
  user: { userId: string; name: string };
  amount: number;       // negative = debit, positive = credit
  balanceAfter: number; // remaining after this row
  type: 'allocation' | 'generation' | 'refund' | 'adjustment';
  operationType?: 'image' | 'video' | 'audio' | 'text' | 'other';
  metadata?: { jobId?, modelName?, ... };
  createdAt: Date;
}
erDiagram
  PROJECTS ||--o{ CREDIT_TRANSACTIONS : has
  PROJECTS {
    string projectId
    object credit
  }
  CREDIT_TRANSACTIONS {
    string transactionId
    object project
    object user
    number amount
    number balanceAfter
    string type
  }

Indexes (creditTransactions)

Compound indexes are created on first collection access (CreditTransactionsModelcreateIndexes). They back idempotency lookups and project ledger listing.

Index Covers
project.projectId + type + metadata.jobId findGenerationByJobId, findRefundByJobId
project.projectId + type + metadata.guruMessageId findGenerationByGuruMessageId
project.projectId + type + metadata.creditOperationId findGenerationByCreditOperationId (ElevenLabs / external ops)
project.projectId + createdAt listByProjectId

Realtime updates

After any balance change, CreditsPublisher sends JaduSpine event projectCreditBalanceUpdated (PROJECT_CREDIT_EVENTS.BALANCE_UPDATED) to all project participants (owner + collaborators).

Payload shape: ProjectCreditBalancePayload{ projectId, credit, remaining }.


Errors

Error HTTP When
CreditsExhaustedError 402 allocatedBudget is 0/null with no bypass, or remaining < cost
Messages CREDIT_ERROR_MESSAGES in credits.constants.ts

Configuration (credits.constants.ts)

Constant Purpose
CREDIT_DEFAULT_ALLOCATED_BUDGET_BY_ROLE Role ID → default budget on project create
BYPASS_FOR_ROLES Extra role IDs that always skip debit (e.g. SUPER_ADMIN)
FEAT_CREDITS_BYPASS RBAC permission to skip debit
FEAT_PROJECT_CREDIT_USAGE Exported for other modules / RBAC setup (not used for tier resolution today)

Bypass checks use the authenticated user from request context (getAuthUser()): roleIds, JWT permissions, and canUserDo(FEAT_CREDITS_BYPASS).

Bypass ledger rows: When the caller bypasses credits, generation operations still write a creditTransactions row with amount = 0 (and metadata indicating bypass) for auditability and idempotency, but do not change projects.credit.used.


File map

src/credits/
  README.md                 ← this file
  credits.constants.ts      ← budgets, permissions, error copy
  credits.types.ts          ← ProjectCredit, payloads, ConsumeCreditsInput
  credits.utils.ts          ← modelConfig cost helpers
  credits.service.ts        ← core logic
  credits.publisher.ts      ← JaduSpine balance fan-out

src/models/
  creditTransactions.model.ts  ← ledger types + queries

tests/credits/
  credits.service.test.ts

Main service methods

Method Role
buildInitialProjectCredit(user) Set allocatedBudget from user's tier roleIds
assertCanGenerate({ projectId, cost }) Pre-check before debit
chargeForAssetGenJob(...) Debit on job create
chargeForGuruChatMessage(...) Debit per assistant turn (TEXT, idempotent per guruMessageId)
refundGenerationForFailedJob(job) Restore on FAILED / CANCELLED
allocateProjectCredits(...) Admin sets allocatedBudget
listTransactions(projectId) Ledger history

Running tests

npm test -- tests/credits/credits.service.test.ts

Future / not in this folder

  • Admin HTTP routes for allocateProjectCredits / listTransactions may live under projects when exposed.
  • Stage-scoped credits are not modeled yet; only project-level credit on projects.