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:
- If
costConfig.rulesmatch job inputs → use rule cost (includingcostPerUnitwhen configured). - Otherwise →
costConfig.defaultCost. - 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 (CreditTransactionsModel → createIndexes). 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/listTransactionsmay live underprojectswhen exposed. - Stage-scoped credits are not modeled yet; only project-level
creditonprojects.