Credit System
Credits are the internal currency for AI operations on the AIQLick platform. One credit equals one dollar of AI token consumption. All billable AI operations deduct credits from the appropriate balance before or after execution, depending on the operation type.
AI Operation Types
Each AI operation maps to a specific model and has a defined billable status:
| Operation Type | Model / Service | Billable | Billing Integration |
|---|---|---|---|
CV_EXTRACTION | Claude Sonnet 4.5 | Yes | pre_check + check_and_consume |
JOB_PARSING | Claude Sonnet 4.5 | Yes | pre_check + check_and_consume |
MEETING_INSIGHT | Claude Haiku 4.5 | Yes | pre_check + check_and_consume |
TEXT_REWRITE | Claude Haiku 4.5 | Yes | pre_check + check_and_consume |
AGENT_CHAT | Amazon Nova Lite | Yes | pre_check + check_and_consume |
FACE_EXTRACTION | AWS Rekognition | Yes | pre_check + check_and_consume |
VOICE_CHAT | Amazon Nova Sonic | Yes | pre_check + check_and_consume (TTS + STT) |
MEETING_TRANSCRIPTION | AWS Transcribe | Yes | pre_check + check_and_consume (Jigasi WS) |
EMBEDDING | Amazon Titan Embed v2 | Yes | pre_check + check_and_consume (document worker) |
DOCUMENT_OCR | Claude Haiku 4.5 (OCR) | Yes | check_and_consume only |
MATCHING | Claude Sonnet (LLM skill extraction only) | Partial | Inner skill extractor meters; outer matching subscription does not gate |
ADD_CANDIDATE | N/A (CPU-only pipeline) | No | pre_check only (defensive gate — no LLM to charge) |
Defensive-only gates
ADD_CANDIDATE and EMBEDDING run pre_check at the pipeline entry point but carry no AI cost on the addCandidate side (it's a CPU-only validation + DB-write pipeline). The pre_check exists so UNPAID POSTPAID scopes and insufficient-credit PREPAID scopes fail fast instead of wasting work.
Non-billable paths
- Matching subscriptions (
userJobMatching,candidateJobMatching,basicMatching) — deterministic scoring, no outer billing gate. The inner LLM skill extractor (invoked during matching when needed) does meter its own work throughBillingGate. - Add Candidate pipeline — pure CPU pipeline that validates input and creates records. The defensive
pre_checkabove does NOT record consumption — there's no LLM call to attribute cost to.
Inbound Email Workers
The inbound email workers (jobs+@aiqlick.com and cv+@aiqlick.com) are billed indirectly. They delegate to the underlying pipeline services (CVExtractionService and JobParsingService) which handle BillingGate integration internally. The workers run pre_check() at the IMAP processing stage to fail fast before downloading attachments.
BillingGate Enforcement
The BillingGate class in background-tasks (app/services/billing/billing_gate.py) is the central enforcement point for all billable AI operations. It provides five public methods: pre_check(), check_and_consume(), log_failure(), log_rejected(), and three internal helpers.
For standard PREPAID plans, every operation follows a three-step flow. For POSTPAID (enterprise) plans, the pre-check returns immediately with unlimited balance, and consumption only logs to AIOperationLog without deducting credits. See Enterprise Post-Paid Billing for details.
Internal Architecture
The BillingGate uses three private helper methods to eliminate duplication across its public methods:
| Helper | Purpose |
|---|---|
_require_billing_context() | Validates at least one of company_id or user_id is present. Raises BillingError when billing is enabled and both are None. |
_resolve_billing_mode() | Resolves PREPAID or POSTPAID by looking up the company's active subscription (via Company.billingOwnerId); if the company has no active subscription, falls through to the caller's personal subscription. Returns a tuple of (billing_mode, has_active_subscription). |
_check_enterprise_blocked() | Checks for an UNPAID POSTPAID subscription (company-scoped or personal). Enforced even when BILLING_ENABLED=false. |
Step 1: Pre-check
await BillingGate.pre_check(company_id, operation_type, model_id, user_id)
The pre-check runs the following validation sequence:
- Billing context -- at least one of
company_idoruser_idmust be present. - Billing mode resolution -- looks up the company's active subscription via
Company.billingOwnerIdto determine PREPAID or POSTPAID. If the company has no active subscription, falls through to the caller's personal subscription before defaulting to PREPAID. This covers users who hold a personal POSTPAID plan but operate inside a company that has no subscription of its own. - POSTPAID fast path -- if an active POSTPAID subscription exists (company-scoped or personal), returns
Trueimmediately (unlimited access). - Enterprise blocking -- if no active subscription exists, checks for an
UNPAIDPOSTPAID subscription on either scope and blocks if found. This check runs even whenBILLING_ENABLED=false. - BILLING_ENABLED gate -- if billing is disabled, returns
True(skips remaining PREPAID checks). - Subscription active -- the company has an active subscription.
- Plan limit not exceeded -- the monthly usage count for this operation type has not reached the plan's configured limit.
- Sufficient credits -- the credit balance covers the estimated cost of the operation.
If any check fails, the operation is rejected with InsufficientCreditsError, PlanLimitExceededError, or SubscriptionInactiveError.
Step 2: Execute Pipeline
The AI pipeline runs (CV parsing, job parsing, text rewrite, etc.). Token usage is tracked during execution.
Step 3: Record Consumption
await BillingGate.check_and_consume(
company_id=..., operation_type=..., model_id=...,
input_tokens=..., output_tokens=...,
reference_id=..., user_id=..., description=..., duration_ms=...
)
Credit deduction uses a pessimistic lock (SELECT ... FOR UPDATE) on the CreditBalance row to prevent race conditions under concurrent operations. The method:
- Validates billing context (
_require_billing_context). - Resolves billing mode if not provided (
_resolve_billing_mode). - POSTPAID path -- logs to
AIOperationLogwithbilling_mode="POSTPAID"metadata, returnsbalance_after=infinity. No credit deduction. This happens regardless ofBILLING_ENABLEDflag. - Billing disabled -- logs the operation with
credits_cost=0andbilling_disabled=Truemetadata for visibility. ReturnsNone. - Billing enabled (PREPAID) -- calculates the credit cost based on token usage and the configured rate for the model, deducts from
CreditBalance, writes aCONSUMPTIONtransaction, and writes theAIOperationLogentry.
Step 4: Log Failure or Rejection
# On pipeline failure (no credits charged):
await BillingGate.log_failure(company_id, operation_type, model_id, error, user_id)
# On billing pre-check rejection:
await BillingGate.log_rejected(company_id, operation_type, rejection_reason)
Both methods write to AIOperationLog for audit trail purposes without charging credits.
Billing Context Skip Behavior
When both company_id and user_id are None, all BillingGate methods silently skip:
pre_check()returnsTrue(operation proceeds).check_and_consume()returnsNone(no charge, no log).log_failure()/log_rejected()return silently.
When BILLING_ENABLED=false and billing context is None, a warning is logged but the operation proceeds. When BILLING_ENABLED=true and both IDs are None, a BillingError is raised.
Subscription Resolution (User-Based Model)
Subscriptions are user-based — they belong to the subscribing user. Companies inherit access via the user responsible for them. Both the backend (PlanLimitsService.getCompanyPlanLimits) and background-tasks (SubscriptionModel.get_active_subscription) resolve the responsible user in one pass using COALESCE(Company.billingOwnerId, earliest Company Admin) and then do a single subscription lookup against that user:
- All subscription queries filter by
status IN ('ACTIVE', 'TRIALING')andisActive = true. - billingOwnerId → Company Admin fallback covers companies that pre-date the write-time auto-populate (below): if a company has
billingOwnerId = NULL, the resolver picks the earliest user holding theCompany Adminrole on that company and looks up their subscription. Only theCompany Adminrole qualifies — Recruiter, Internal Sales, and regular members are ignored. - The third leg (
caller's user_id) covers subscriptions created in personal scope (Subscription.companyId = NULL) when the operation is either job-seeker style (no company context) or happens inside a company that still has no billingOwner and no Company Admin. - Company credit balances are independent of subscription resolution. A company owns its own
CreditBalancerow; subscription inheritance only controls who is authorized to route credits in and out and what plan entitlements apply, not who owns the ledger.
billingOwnerId is set automatically at every write path
To keep the fallback from having to fire at runtime, every path that makes a user responsible for a company now populates Company.billingOwnerId as a side effect (idempotent — each update only runs where billingOwnerId IS NULL):
| Write path | When it sets billingOwnerId |
|---|---|
createCompany GraphQL mutation | Creator becomes the billing owner |
UserCompanyRoleService.create | User assigned the Company Admin role |
AdminRoleManagementService.assignRole + bulkAssignRole | Admin assigns Company Admin |
AdminCompanyService.transferOwnership | Ownership transfer points at the new owner |
Invitation acceptance (auth.service.ts) | Invitee joins as Company Admin |
SubscriptionService.create (non-Stripe path) | Company-scoped subscription created |
adminAssignSubscription | Subscription assigned to a company (resolved billing owner also stored on the Subscription row) |
adminCreateEnterpriseSubscription / adminApproveEnterpriseSubscription | Company-scope sets the target company; personal-scope propagates to every company the user admins |
Stripe checkout.session.completed webhook | Already set this since initial Stripe integration |
One-off data backfill
For data that pre-dates the write-time auto-populate, the super-admin mutation adminBackfillCompanyBillingOwners runs a single idempotent UPDATE that sets billingOwnerId to the earliest Company Admin for every company currently NULL. Safe to re-run; returns the rowcount. See Payment API — Backfill Company Billing Owners for the schema.
Initial Credit Grants
Every new user gets 9 credits (configurable via JOB_SEEKER_INITIAL_CREDITS env var) automatically on account creation. This is implemented via a Prisma $use middleware that emits a user.created event on every User.create() operation. The CreditSubscriptionListener handles the event and calls grantInitialJobSeekerCredits(), which has an idempotency check (skips if INITIAL_GRANT transaction already exists).
Plan Feature Limits
Plans define 17 feature limit fields. Non-AI limits (14 fields) are enforced by the @CheckPlanLimit guard in the backend. AI operation limits (maxCvParsesPerMonth, maxAgentMessagesPerMonth, maxTextRewritesPerMonth) are enforced by the BillingGate in background-tasks. maxCompanies is enforced directly in company.service.ts:
| Field | Description |
|---|---|
maxJobs | Maximum number of active job postings |
maxTalentPools | Maximum talent pools a company can create |
maxContacts | Maximum contacts stored |
maxPipelines | Maximum recruitment pipelines |
maxInterviewsPerMonth | Monthly interview scheduling limit |
maxMeetingsPerMonth | Monthly video meeting limit |
maxCvCollections | Maximum CV collection groups |
maxSharedTalentLists | Maximum externally shared talent lists |
maxCollaborationInvites | Maximum pending collaboration invitations |
maxConnectionRequestsPerMonth | Monthly connection request limit |
maxUsersPerCompany | Maximum team members per company |
maxNewsPerMonth | Monthly news post limit |
maxContactNotes | Maximum notes per contact |
maxCvParsesPerMonth | Monthly CV parsing limit (AI operation) |
maxAgentMessagesPerMonth | Monthly AI agent message limit |
maxTextRewritesPerMonth | Monthly text rewrite limit (AI operation) |
maxCompanies | Maximum companies a user can create |
Limit values follow a consistent convention:
-1-- unlimited (no restriction).0-- disabled (feature not available on this plan).> 0-- the specific numeric limit.
Credit Cost Configuration
The CreditCostConfig table allows administrators to configure credit pricing per AI model. Each row defines:
| Field | Description |
|---|---|
modelId | AI model identifier (e.g., eu.anthropic.claude-sonnet-4-5-20250514-v1:0) |
displayName | Human-readable model name |
inputCostPer1k | Credits charged per 1,000 input tokens |
outputCostPer1k | Credits charged per 1,000 output tokens |
flatCostPerCall | Optional flat credit cost per API call (added to token-based cost) |
markupPercent | Optional markup applied as base_cost * (1 + markup/100) |
isActive | Whether this config is used for cost calculation |
Cost Calculation Formula
input_cost = (input_tokens / 1000) * inputCostPer1k
output_cost = (output_tokens / 1000) * outputCostPer1k
base_cost = input_cost + output_cost
final_cost = base_cost * (1 + markupPercent / 100) # if markup configured
The CostCalculator in background-tasks prioritizes database CreditCostConfig entries over hardcoded fallback rates, with a 5-minute in-memory cache to minimize database lookups.
Cost configuration is admin-managed via the GraphQL API (createCreditCostConfig, updateCreditCostConfig). Prices can be adjusted as model costs change without deploying code. One credit equals one dollar of AI token consumption.
AI Operation Log
Every AI operation -- whether successful or not -- is recorded in the AIOperationLog table. Each entry includes:
- User and company identifiers.
- Operation type and model used.
- Input and output token counts.
- Calculated credit cost.
- Execution duration.
- Final status.
Operation Statuses
| Status | Meaning |
|---|---|
SUCCESS | Operation completed and credits were deducted |
FAILED | Operation failed during execution (no credits deducted) |
INSUFFICIENT_CREDITS | Pre-check rejected: balance too low |
PLAN_LIMIT_EXCEEDED | Pre-check rejected: monthly limit reached |
SUBSCRIPTION_INACTIVE | Pre-check rejected: no active subscription |
RATE_LIMITED | Pre-check rejected: request rate exceeded |
Failed operations do not consume credits. The log provides a complete audit trail for billing disputes and usage analytics.
Credit Pack Catalog
Credit packs are created via the admin createCreditPack GraphQL mutation, which auto-syncs each pack to a live Stripe Product and one-time Price. See Credit Pack Stripe Auto-Sync for the full sync mechanism.
The default production catalog (USD, strict 1:1 credit-to-dollar pricing):
| Pack | Credits | Price | Stripe Product Name |
|---|---|---|---|
| Starter | 50 | $50 | Credit Pack: Starter |
| Growth | 250 | $250 | Credit Pack: Growth |
| Scale | 1,000 | $1,000 | Credit Pack: Scale |
| Enterprise | 5,000 | $5,000 | Credit Pack: Enterprise |
Each pack has the following invariants enforced by createCreditPack:
- A
CreditPackrow is inserted withstatus='ACTIVE'andcurrency='usd' - A live Stripe Product is created with
metadata.creditPackIdmatching the row's UUID - A one-time Stripe Price is created with the same metadata
- The DB row's
stripeProductIdandstripePriceIdcolumns are populated post-creation
To add new tiers, call createCreditPack rather than inserting directly into the DB — direct inserts will leave the Stripe side unsynced. To change pricing on an existing pack, use updateCreditPack; the resolver creates a new Stripe Price (Stripe prices are immutable) and updates the DB row.
The default catalog uses strict 1:1 pricing (1 credit = $1) so margin lives in the spread between platform billing and Bedrock cost. If you want to incentivize larger purchases with bonus credits (e.g. 1,100 credits for $1,000), call updateCreditPack to bump the credits field — Stripe pricing stays the same.
Credit Transfers
transferCredits moves credits between balances. Both source (fromCompanyId) and target (toCompanyId) are optional; at least one must be set, and they must differ. Omitting one means "the caller's personal balance" on that side.
mutation {
transferCredits(input: {
fromCompanyId: "company-uuid" # omit to debit the caller's personal balance
toCompanyId: "company-uuid" # omit to credit the caller's personal balance
amount: 100 # min 0.01
}) {
success
message
}
}
Supported directions
fromCompanyId | toCompanyId | Direction | Authorization |
|---|---|---|---|
| omitted | X | Personal → Company X | Caller must be billingOwnerId of target (X) |
X | Y | Company X → Company Y | Caller must be billingOwnerId of source (X) |
X | omitted | Company X → Personal | Caller must be billingOwnerId of source (X) |
| omitted | omitted | Rejected (BadRequest) | — |
X | X | Rejected (BadRequest) | — |
Ledger semantics
Every transfer runs in a single Prisma $transaction with pessimistic locks on both CreditBalance rows (SELECT ... FOR UPDATE). Two CreditTransaction records are written, both with type: TRANSFER:
- Debit side (
amount = -X):referenceIdpoints to the counterparty (target),referenceTypeisCompanyTransferwhen the target is a company orUserTransferwhen the target is personal. - Credit side (
amount = +X):referenceIdpoints to the counterparty (source),referenceTypemirrors the source scope the same way.
Both rows record performedById = caller.id. The target CreditBalance row is auto-created if missing (for either user-scope or company-scope).
Company Billing Configuration
The CompanyBillingConfig table provides per-company overrides for billing behavior:
- Credit allocation percentage -- controls what fraction of plan credits are allocated to the company balance versus the billing owner's personal balance.
- Feature limit overrides -- allows specific companies to have higher (or lower) limits than their plan defines, useful for enterprise agreements or promotional arrangements.
These overrides take precedence over the base plan configuration when present.