Skip to main content

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 TypeModel / ServiceBillableBilling Integration
CV_EXTRACTIONClaude Sonnet 4.5Yespre_check + check_and_consume
JOB_PARSINGClaude Sonnet 4.5Yespre_check + check_and_consume
MEETING_INSIGHTClaude Haiku 4.5Yespre_check + check_and_consume
TEXT_REWRITEClaude Haiku 4.5Yespre_check + check_and_consume
AGENT_CHATAmazon Nova LiteYespre_check + check_and_consume
FACE_EXTRACTIONAWS RekognitionYespre_check + check_and_consume
VOICE_CHATAmazon Nova SonicYespre_check + check_and_consume (TTS + STT)
MEETING_TRANSCRIPTIONAWS TranscribeYespre_check + check_and_consume (Jigasi WS)
EMBEDDINGAmazon Titan Embed v2Yespre_check + check_and_consume (document worker)
DOCUMENT_OCRClaude Haiku 4.5 (OCR)Yescheck_and_consume only
MATCHINGClaude Sonnet (LLM skill extraction only)PartialInner skill extractor meters; outer matching subscription does not gate
ADD_CANDIDATEN/A (CPU-only pipeline)Nopre_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 through BillingGate.
  • Add Candidate pipeline — pure CPU pipeline that validates input and creates records. The defensive pre_check above 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:

HelperPurpose
_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:

  1. Billing context -- at least one of company_id or user_id must be present.
  2. Billing mode resolution -- looks up the company's active subscription via Company.billingOwnerId to 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.
  3. POSTPAID fast path -- if an active POSTPAID subscription exists (company-scoped or personal), returns True immediately (unlimited access).
  4. Enterprise blocking -- if no active subscription exists, checks for an UNPAID POSTPAID subscription on either scope and blocks if found. This check runs even when BILLING_ENABLED=false.
  5. BILLING_ENABLED gate -- if billing is disabled, returns True (skips remaining PREPAID checks).
  6. Subscription active -- the company has an active subscription.
  7. Plan limit not exceeded -- the monthly usage count for this operation type has not reached the plan's configured limit.
  8. 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:

  1. Validates billing context (_require_billing_context).
  2. Resolves billing mode if not provided (_resolve_billing_mode).
  3. POSTPAID path -- logs to AIOperationLog with billing_mode="POSTPAID" metadata, returns balance_after=infinity. No credit deduction. This happens regardless of BILLING_ENABLED flag.
  4. Billing disabled -- logs the operation with credits_cost=0 and billing_disabled=True metadata for visibility. Returns None.
  5. Billing enabled (PREPAID) -- calculates the credit cost based on token usage and the configured rate for the model, deducts from CreditBalance, writes a CONSUMPTION transaction, and writes the AIOperationLog entry.

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() returns True (operation proceeds).
  • check_and_consume() returns None (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') and isActive = 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 the Company Admin role on that company and looks up their subscription. Only the Company Admin role 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 CreditBalance row; 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 pathWhen it sets billingOwnerId
createCompany GraphQL mutationCreator becomes the billing owner
UserCompanyRoleService.createUser assigned the Company Admin role
AdminRoleManagementService.assignRole + bulkAssignRoleAdmin assigns Company Admin
AdminCompanyService.transferOwnershipOwnership 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
adminAssignSubscriptionSubscription assigned to a company (resolved billing owner also stored on the Subscription row)
adminCreateEnterpriseSubscription / adminApproveEnterpriseSubscriptionCompany-scope sets the target company; personal-scope propagates to every company the user admins
Stripe checkout.session.completed webhookAlready 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:

FieldDescription
maxJobsMaximum number of active job postings
maxTalentPoolsMaximum talent pools a company can create
maxContactsMaximum contacts stored
maxPipelinesMaximum recruitment pipelines
maxInterviewsPerMonthMonthly interview scheduling limit
maxMeetingsPerMonthMonthly video meeting limit
maxCvCollectionsMaximum CV collection groups
maxSharedTalentListsMaximum externally shared talent lists
maxCollaborationInvitesMaximum pending collaboration invitations
maxConnectionRequestsPerMonthMonthly connection request limit
maxUsersPerCompanyMaximum team members per company
maxNewsPerMonthMonthly news post limit
maxContactNotesMaximum notes per contact
maxCvParsesPerMonthMonthly CV parsing limit (AI operation)
maxAgentMessagesPerMonthMonthly AI agent message limit
maxTextRewritesPerMonthMonthly text rewrite limit (AI operation)
maxCompaniesMaximum 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:

FieldDescription
modelIdAI model identifier (e.g., eu.anthropic.claude-sonnet-4-5-20250514-v1:0)
displayNameHuman-readable model name
inputCostPer1kCredits charged per 1,000 input tokens
outputCostPer1kCredits charged per 1,000 output tokens
flatCostPerCallOptional flat credit cost per API call (added to token-based cost)
markupPercentOptional markup applied as base_cost * (1 + markup/100)
isActiveWhether 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.

tip

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

StatusMeaning
SUCCESSOperation completed and credits were deducted
FAILEDOperation failed during execution (no credits deducted)
INSUFFICIENT_CREDITSPre-check rejected: balance too low
PLAN_LIMIT_EXCEEDEDPre-check rejected: monthly limit reached
SUBSCRIPTION_INACTIVEPre-check rejected: no active subscription
RATE_LIMITEDPre-check rejected: request rate exceeded

Failed operations do not consume credits. The log provides a complete audit trail for billing disputes and usage analytics.

Usage insights (frontend)

The frontend surfaces this log on the credits settings tab as per-period usage insights — a Cost-by-Feature breakdown, a usage-over-time chart, an admin-only By-Team-Member table, and a Companies-You-Own rollup. See Billing & Credits (frontend) for the UI.

Value parity

The two surfaces read the log differently, so their totals can legitimately diverge:

  • Cost-by-Feature uses aiOperationUsageSummary, which has no status filter — it counts every AIOperationLog row in the period, so it can exceed invoiced totals (failed/rejected ops carry credits_cost = 0 but still appear as calls).
  • By-Team-Member pages aiOperationLogs with status: SUCCESS, so it only sums successfully-charged operations.

The By-Team-Member figure is therefore a subset of the Cost-by-Feature figure and should never be reconciled 1:1 against it.

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):

PackCreditsPriceStripe Product Name
Starter50$50Credit Pack: Starter
Growth250$250Credit Pack: Growth
Scale1,000$1,000Credit Pack: Scale
Enterprise5,000$5,000Credit Pack: Enterprise

Each pack has the following invariants enforced by createCreditPack:

  • A CreditPack row is inserted with status='ACTIVE' and currency='usd'
  • A live Stripe Product is created with metadata.creditPackId matching the row's UUID
  • A one-time Stripe Price is created with the same metadata
  • The DB row's stripeProductId and stripePriceId columns 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.

Strict 1:1 vs bonus credits

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

fromCompanyIdtoCompanyIdDirectionAuthorization
omittedXPersonal → Company XCaller must be billingOwnerId of target (X)
XYCompany X → Company YCaller must be billingOwnerId of source (X)
XomittedCompany X → PersonalCaller must be billingOwnerId of source (X)
omittedomittedRejected (BadRequest)
XXRejected (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): referenceId points to the counterparty (target), referenceType is CompanyTransfer when the target is a company or UserTransfer when the target is personal.
  • Credit side (amount = +X): referenceId points to the counterparty (source), referenceType mirrors 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.