Skip to main content

Enterprise Post-Paid Billing

Enterprise customers receive unlimited platform access without upfront payment. They are hand-picked, trusted scopes — either an organization (company-scoped) or an individual (personal, companyId=null) — whose subscriptions are managed by a super admin. At the end of each month, an invoice is generated based on actual AI usage (token costs from CV parsing, agent chat, matching, and other operations). The scope has 5 days to pay. If payment is not received, platform access is blocked until the invoice is settled.

Plans are user-based

Per the standing rule "Plans are user-based: Subscriptions allow companyId=null; never require a company to buy a plan", an enterprise subscription can be created without a company and tied directly to a billing owner (userId). The entire lifecycle — create, approve, block, restore, invoice, usage breakdown — supports both company-scoped and personal subscriptions.

Architecture

The enterprise billing system differs from the standard prepaid model in two fundamental ways:

  1. No credit balance -- enterprise companies bypass all credit checks. The BillingGate returns balance=infinity for POSTPAID subscriptions.
  2. Usage-based invoicing -- AI operations are logged to AIOperationLog without credit deduction. These logs serve as the source of truth for monthly invoice generation.

How It Works

1. Subscription Creation

An enterprise subscription can be created in two ways:

Admin-initiated (immediate activation): A super admin calls adminCreateEnterpriseSubscription with the plan, billing owner, and optionally a company. When companyId is provided, the subscription is company-scoped; when omitted, it is a personal subscription owned by the billing owner (Subscription.companyId = NULL, Subscription.userId = billingOwnerId). The subscription is immediately set to ACTIVE with isActive: true. Any existing active subscriptions in the same scope (company or personal) are automatically canceled.

User-initiated (requires approval): A user calls requestEnterpriseSubscription. A company member passes companyId (validated against UserCompanyRole); a user without a company omits it to request a personal subscription. Either path creates a subscription with PENDING_APPROVAL status and isActive: false. The admin is notified and must explicitly approve the request via adminApproveEnterpriseSubscription.

2. Unlimited Access

Once active, the enterprise company has unlimited access to all platform features:

  • All 16 plan limits are set to -1 (unlimited).
  • No creditsPerMonth allocation is needed (set to 0).
  • The BillingGate.pre_check() in background-tasks detects the POSTPAID billing mode and returns immediately with balance=infinity.
  • No credit balance row is required for the company.

3. Usage Logging

Every billable AI operation (CV extraction, job parsing, meeting insight, text rewrite, agent chat, face extraction) is logged to the AIOperationLog table. For POSTPAID companies, the BillingGate.check_and_consume() method:

  • Calculates the credit cost based on token usage and configured rates.
  • Writes the AIOperationLog entry with metadata.billing_mode = "POSTPAID".
  • Does not deduct from any credit balance.
  • Returns balance_after = infinity.

This logging happens regardless of the BILLING_ENABLED environment variable. Enterprise usage is always tracked because AIOperationLog is the source of truth for invoice generation.

4. Monthly Invoice Generation

A cron job runs on the 1st of each month at 3:00 AM UTC (0 3 1 * *). It:

  1. Finds all active POSTPAID subscriptions (both company-scoped and personal — no companyId IS NOT NULL filter).
  2. For each scope, queries AIOperationLog for the previous calendar month (grouped by operationType), filtered by companyId for company-scoped subs or by {companyId IS NULL, userId} for personal subs.
  3. Calculates the total cost from creditsCost fields on successful operations.
  4. If total usage is zero, skips invoice generation for that scope.
  5. Creates a Stripe invoice with collection_method: send_invoice and days_until_due: 5. The Stripe customer name is the company name (company-scoped) or the billing owner's full name / email (personal).
  6. Creates a local Invoice record with the billing period, due date, companyId (or NULL), and userId = billingOwnerId.
  7. Emits the enterprise.invoice.generated event (triggers email and in-app notification to the billing owner).

Stripe sends the billing owner an email with a hosted payment link.

5. Overdue Check and Access Blocking

A second cron job runs daily at 10:00 AM UTC (0 10 * * *). It:

  1. Finds overdue enterprise invoices — identified by billingPeriodStart IS NOT NULL and the linked Subscription.Plan.billingMode = 'POSTPAID'. Includes both company-scoped (companyId set) and personal (companyId IS NULL) invoices.
  2. Updates the invoice status to OVERDUE.
  3. Blocks the scope: finds the matching active POSTPAID subscription (by companyId or {userId, companyId: null}) and sets its status to UNPAID / isActive: false.
  4. Emits enterprise.invoice.overdue and enterprise.access.blocked events with companyId: string | null.

When a scope is blocked, the BillingGate.pre_check() in background-tasks detects the UNPAID status and raises a SubscriptionInactiveError. This check is enforced even when BILLING_ENABLED is set to false — blocked enterprise scopes (company or personal) cannot bypass access control.

6. Payment and Access Restoration

Access is restored through one of three paths:

  • Stripe webhook — When the billing owner pays the Stripe invoice, the invoice.paid webhook fires. The handler detects the enterprise invoice (no Stripe subscription ID, local invoice with billingPeriodStart set), looks up the UNPAID POSTPAID subscription by companyId when present or by {userId: localInvoice.userId, companyId: null} for personal subs, restores it to ACTIVE, and emits enterprise.access.restored.
  • Admin manual overrideadminMarkInvoicePaid(invoiceId) updates the invoice to PAID and restores the subscription in the matching scope.
  • Admin direct restoreadminRestoreEnterpriseAccess(input: { companyId | userId }) directly restores access without modifying the invoice.

Plan Configuration

The Enterprise plan is configured with the following properties:

FieldValueDescription
nameEnterprisePlan display name
price0No recurring charge (usage-based)
billingModePOSTPAIDDistinguishes from standard prepaid plans
creditsPerMonth0No upfront credit allocation
All 16 feature limits-1Unlimited access to all features
trialDays0No trial period (admin-managed)
info

The billingMode field on the Plan model is what drives all enterprise-specific behavior. Both the backend (subscription management, invoice generation) and background-tasks (billing gate enforcement, usage logging) check this field to determine the billing path.

Subscription Lifecycle

Admin-initiated flow skips PENDING_APPROVAL and goes directly to ACTIVE:

Status Definitions

StatusisActiveMeaning
PENDING_APPROVALfalseUser requested, awaiting admin approval
ACTIVEtrueFull platform access, AI operations logged for invoicing
UNPAIDfalseInvoice overdue, all platform features blocked
CANCELEDfalseRejected request or replaced by a new subscription

Admin API

All enterprise mutations and queries require JwtAuthGuard + SuperAdminGuard (except requestEnterpriseSubscription, which is available to authenticated users).

Mutations

adminCreateEnterpriseSubscription

Creates an enterprise subscription immediately in ACTIVE status. companyId is optional — when omitted, the subscription is a personal sub tied to the billing owner. Cancels any existing active subscriptions in the same scope.

# Company-scoped
mutation {
adminCreateEnterpriseSubscription(input: {
companyId: "company-uuid"
planId: "enterprise-plan-uuid"
billingOwnerId: "user-uuid"
}) {
id
status
isActive
startDate
Plan { name billingMode }
}
}

# Personal (no company)
mutation {
adminCreateEnterpriseSubscription(input: {
planId: "enterprise-plan-uuid"
billingOwnerId: "user-uuid"
}) { id status isActive }
}
input CreateEnterpriseSubscriptionInput {
companyId: ID # Optional — omit for a personal enterprise subscription
planId: ID!
billingOwnerId: ID!
}

adminApproveEnterpriseSubscription

Approves a pending enterprise request. Requires specifying the billing owner (may differ from the requesting user). Cancels any other active subscriptions for the company.

mutation {
adminApproveEnterpriseSubscription(input: {
subscriptionId: "subscription-uuid"
billingOwnerId: "user-uuid"
}) {
id
status
isActive
}
}

adminRejectEnterpriseSubscription

Rejects a pending enterprise request. Sets status to CANCELED. Emits enterprise.subscription.rejected event with the optional reason.

mutation {
adminRejectEnterpriseSubscription(
subscriptionId: "subscription-uuid"
reason: "Company does not meet enterprise criteria"
) {
id
status
}
}

adminBlockEnterpriseAccess

Manually blocks access for an enterprise scope. Sets the matching subscription to UNPAID with isActive: false. Exactly one of companyId or userId must be provided.

# Block a company-scoped enterprise sub
mutation {
adminBlockEnterpriseAccess(input: { companyId: "company-uuid" }) {
id status isActive
}
}

# Block a personal enterprise sub
mutation {
adminBlockEnterpriseAccess(input: { userId: "user-uuid" }) {
id status isActive
}
}
input EnterpriseAccessScopeInput {
companyId: ID # Provide one
userId: ID # OR the other — exactly one is required
}

adminRestoreEnterpriseAccess

Manually restores access for a blocked enterprise scope. Same EnterpriseAccessScopeInput shape as block.

# Restore a company-scoped sub
mutation {
adminRestoreEnterpriseAccess(input: { companyId: "company-uuid" }) {
id status isActive
}
}

# Restore a personal sub
mutation {
adminRestoreEnterpriseAccess(input: { userId: "user-uuid" }) {
id status isActive
}
}

adminGenerateEnterpriseInvoice

Generates an invoice on demand for a specific billing period. Useful for generating invoices outside the monthly cron cycle or re-generating for a specific date range. Exactly one of companyId or userId must be provided.

# Company-scoped invoice
mutation {
adminGenerateEnterpriseInvoice(input: {
companyId: "company-uuid"
startDate: "2026-01-01T00:00:00Z"
endDate: "2026-01-31T23:59:59Z"
}) {
id amount currency status dueDate
billingPeriodStart billingPeriodEnd stripeInvoiceId
}
}

# Personal (no company) invoice — billed to the user directly
mutation {
adminGenerateEnterpriseInvoice(input: {
userId: "user-uuid"
startDate: "2026-01-01T00:00:00Z"
endDate: "2026-01-31T23:59:59Z"
}) { id amount status dueDate }
}
input GenerateEnterpriseInvoiceInput {
companyId: ID # One of
userId: ID # the two
startDate: DateTime!
endDate: DateTime!
}

adminMarkInvoicePaid

Manual payment override. Marks the invoice as PAID and restores access if the company was blocked.

mutation {
adminMarkInvoicePaid(invoiceId: "invoice-uuid") {
success
message
}
}

requestEnterpriseSubscription

Available to any authenticated user (not admin-only). Creates a PENDING_APPROVAL subscription request.

mutation {
requestEnterpriseSubscription(input: {
companyId: "company-uuid"
planId: "enterprise-plan-uuid"
}) {
id
status # PENDING_APPROVAL
isActive # false
}
}
warning

The requesting user must belong to the target company (validated via UserCompanyRole). The company must not already have an active subscription or a pending enterprise request.

Queries

adminEnterpriseSubscriptions

Lists all enterprise subscriptions (all statuses), with company and subscriber details.

query {
adminEnterpriseSubscriptions {
id
status
isActive
startDate
companyId
Plan { name billingMode }
Company { id companyName billingOwnerId }
SubscribedBy { id email firstName lastName }
}
}

adminEnterpriseUsageBreakdown

Returns a usage breakdown for an enterprise scope over a specific period. This is a preview of what the invoice would contain. Scope is XOR: provide either companyId or userId.

# Company-scoped usage
query {
adminEnterpriseUsageBreakdown(
companyId: "company-uuid"
startDate: "2026-01-01T00:00:00Z"
endDate: "2026-01-31T23:59:59Z"
) {
companyId companyName userId userName
periodStart periodEnd
lineItems { operationType operationCount totalCost totalInputTokens totalOutputTokens }
totalAmount currency
}
}

# Personal usage — companyId/companyName return null; userId/userName are populated
query {
adminEnterpriseUsageBreakdown(
userId: "user-uuid"
startDate: "2026-01-01T00:00:00Z"
endDate: "2026-01-31T23:59:59Z"
) {
userId userName totalAmount currency
lineItems { operationType operationCount totalCost }
}
}

The EnterpriseUsageBreakdown type has nullable scope fields:

type EnterpriseUsageBreakdown {
companyId: String # null for personal
companyName: String # null for personal
userId: String # null for company-scoped
userName: String # null for company-scoped
periodStart: DateTime!
periodEnd: DateTime!
lineItems: [EnterpriseUsageLineItem!]!
totalAmount: Float!
currency: String!
}

Invoice Generation Details

Cron Schedule

JobScheduleDescription
Invoice generation0 3 1 * * (1st of month, 3 AM UTC)Generates invoices for previous month's usage
Overdue check0 10 * * * (daily, 10 AM UTC)Blocks companies with past-due invoices

Invoice Creation Process

  1. Calculate the previous month's period: periodStart is the 1st at 00:00 UTC, periodEnd is the last day at 23:59:59.999 UTC.
  2. Query AIOperationLog with groupBy(['operationType']), filtering by companyId, date range, and status: SUCCESS.
  3. Sum creditsCost across all operation types for the total invoice amount.
  4. If total is zero (no billable usage), skip invoice generation.
  5. Look up the billing owner from the subscription (subscribedById or userId).
  6. Create Stripe invoice line items -- one per operation type, with the format "CV Extraction -- 42 operations".
  7. Create a Stripe invoice with collection_method: send_invoice and days_until_due: 5. Stripe metadata includes type: enterprise_usage and the company name.
  8. Finalize and send the Stripe invoice (Stripe emails the billing owner with a payment link).
  9. Create a local Invoice record with PENDING status, linking to the subscription, company, and billing period.

Idempotency

Invoice generation is idempotent at two levels:

  • Application level — before creating, the service checks for an existing invoice matching the scope: {companyId, billingPeriodStart, billingPeriodEnd} for company-scoped, or {companyId: NULL, userId: billingOwnerId, billingPeriodStart, billingPeriodEnd} for personal.
  • Database level — the unique constraint @@unique([companyId, billingPeriodStart, billingPeriodEnd]) on the Invoice table catches concurrent generation attempts for company-scoped invoices (Prisma error P2002 → the service re-fetches and returns the existing row).
Personal invoices rely on the app-level check alone

PostgreSQL treats NULL as distinct in unique constraints, so two concurrent generateMonthlyInvoice calls for the same personal user + period would not collide on the DB unique constraint. The app-level pre-check prevents duplicates for a single cron run, and the monthly cron iterates subscriptions serially — but if personal invoice generation ever becomes parallel across multiple processes, a separate idempotency guard (e.g. an advisory lock keyed on userId + period) will be needed.

Stripe Invoice Resilience

If Stripe invoice creation fails (API error, network issue), the local Invoice record is still created without a stripeInvoiceId. This allows admins to:

  • See the invoice in the admin panel.
  • Manually generate a Stripe invoice later.
  • Use adminMarkInvoicePaid for payments received outside Stripe.

Background Tasks Integration

BillingGate Behavior for POSTPAID

The BillingGate class in background-tasks handles POSTPAID companies differently at each step. It uses three internal helper methods for clean code reuse.

pre_check()

# Simplified flow:
# 1. Validate billing context (company_id or user_id must be present)
has_context = await cls._require_billing_context(company_id, user_id, operation_type)

# 2. Resolve billing mode from active subscription.
# Now accepts user_id so personal POSTPAID subs (companyId IS NULL) are
# resolved correctly — previously this path silently fell back to PREPAID.
billing_mode, has_subscription = await cls._resolve_billing_mode(company_id, user_id)
# Returns tuple: ("PREPAID"|"POSTPAID", True|False)

if billing_mode == "POSTPAID":
# Active POSTPAID subscription found (company or personal) — allow immediately.
# No plan limit check, no credit balance check.
return True

# 3. No active subscription? Check for blocked enterprise in either scope.
if not has_subscription:
await cls._check_enterprise_blocked(company_id, user_id)
# Raises SubscriptionInactiveError if UNPAID POSTPAID exists

_resolve_billing_mode(company_id, user_id) resolution order:

  1. If company_idSubscriptionModel.get_active_subscription(company_id). This is now a single CTE that resolves the responsible user via COALESCE(Company.billingOwnerId, earliest user with 'Company Admin' role on that company) and then JOINs Subscription/Plan. On a hit, return its billing mode.
  2. If that company-side lookup misses (or no company_id was passed), fall through to SubscriptionModel.get_active_user_subscription(user_id) for the caller's personal subscription (Subscription WHERE userId = $1 AND companyId IS NULL AND isActive AND status IN ('ACTIVE','TRIALING')). On a hit, return its billing mode.
  3. Otherwise → ("PREPAID", False).

The Company Admin fallback inside step 1 covers legacy rows where Company.billingOwnerId was never set (companies created before the auto-populate work, seeded fixtures, etc.). Only the Company Admin role qualifies — Recruiter, Internal Sales, and regular members do not. Ties are broken by earliest UserCompanyRole.createdAt. The parallel backend resolver (PlanLimitsService.getCompanyPlanLimits) uses the same COALESCE so both services agree.

The step-2 fall-through covers the enterprise-employee case: a user holds a personal POSTPAID plan (e.g. via adminCreateEnterpriseSubscription with no companyId) but operates inside a company that itself has no subscription and no Company Admin. Without it, the gate would return PREPAID and the operation would fail against an empty company credit balance.

For blocked scopes (subscription status UNPAID), the active-subscription queries return None because they filter by status IN ('ACTIVE', 'TRIALING'). The _check_enterprise_blocked(company_id, user_id) helper then queries either SubscriptionModel.has_unpaid_postpaid_subscription(company_id) or has_unpaid_postpaid_user_subscription(user_id) and raises SubscriptionInactiveError. This check runs even when BILLING_ENABLED is false.

check_and_consume()

# Simplified flow for POSTPAID companies:
if billing_mode == "POSTPAID":
# Log to AIOperationLog -- source of truth for invoicing
await credit_service.record_consumption(
billing_mode="POSTPAID", # Skips credit deduction
...
)
return {"balance_after": infinity}

The record_consumption() method in CreditService:

  • Calculates the credit cost from token usage (same formula as PREPAID).
  • Writes the AIOperationLog entry with credits_cost set to the calculated amount.
  • Adds billing_mode: "POSTPAID" to the metadata JSON.
  • Does not touch CreditBalance or CreditTransaction tables.
  • Returns balance_after = float("inf").
caution

POSTPAID operations are always logged to AIOperationLog regardless of the BILLING_ENABLED flag. This is because the AIOperationLog is the source of truth for monthly invoice generation. Disabling billing should not silently skip enterprise usage tracking.

Agent Tools

The AI agent tools extract_cv and parse_job (app/services/agent/tool_service.py) forward the conversation's user_id alongside company_id when delegating to CVExtractionService / JobParsingService. This is required so that a personal-enterprise user calling these tools has their usage correctly attributed and gated through the personal-POSTPAID path. Tool payload shape:

payload = {
"id": f"agent-cv-{int(time.time())}",
"file_url": file_url,
"companyId": self.company_id,
"userId": self.user_id, # ← required for personal enterprise billing
"sequential": True,
}

Backend Plan-Limit Guard

The backend PlanLimitGuard (src/payment/plan-limits/guards/plan-limit.guard.ts) calls checkEnterpriseAccessBlocked first (same as before), then probes planLimitsService.getCompanyPlanLimits(companyId)?.billingMode and early-returns for POSTPAID before invoking checkLimit. This is a defense-in-depth safeguard: POSTPAID plans are supposed to carry -1 for every limit by convention, but if a POSTPAID plan accidentally has a finite limit in DB, the guard still won't rate-limit the company.

Access Blocking When Billing Is Disabled

Even when BILLING_ENABLED=false (which skips all PREPAID billing checks), BillingGate.pre_check() explicitly checks for blocked enterprise scopes via the _check_enterprise_blocked() helper:

# _check_enterprise_blocked(company_id, user_id) delegates to SubscriptionModel:
if company_id:
if await SubscriptionModel.has_unpaid_postpaid_subscription(company_id):
raise SubscriptionInactiveError(company_id=company_id, status="UNPAID")
elif user_id:
if await SubscriptionModel.has_unpaid_postpaid_user_subscription(user_id):
raise SubscriptionInactiveError(company_id=None, status="UNPAID")

The company-scoped query joins Company → Subscription → Plan. The user-scoped query (has_unpaid_postpaid_user_subscription) filters directly on Subscription WHERE userId = $1 AND companyId IS NULL AND Plan.billingMode = 'POSTPAID' AND status = 'UNPAID'. This ensures that blocking any enterprise scope for non-payment is always enforced, regardless of the billing feature flag state.

caution

This check only runs when no active subscription is found. If the scope has an active PREPAID subscription, the enterprise blocking check is skipped (a scope can only have one active subscription at a time).

Stripe Webhook Integration

invoice.paid Handler

When a Stripe invoice is paid, the webhook handler checks if it is an enterprise invoice:

  1. The event has a stripeInvoiceId but no stripeSubscriptionId (enterprise invoices are standalone, not tied to a Stripe subscription).
  2. The handler looks up the local Invoice by stripeInvoiceId.
  3. If the invoice has billingPeriodStart set (the enterprise-invoice marker), the handler looks up the matching UNPAID POSTPAID subscription: by companyId when present, or by {userId: localInvoice.userId, companyId: null} for personal invoices.
  4. If found, restores the subscription to ACTIVE and emits enterprise.access.restored with companyId: string | null.

This provides automatic access restoration for both company-scoped and personal enterprise subs when the billing owner pays via the Stripe-hosted payment link.

createUsageInvoice (Stripe Service)

The StripeService.createUsageInvoice() method:

  1. Gets or creates a Stripe customer for the billing owner.
  2. Creates a Stripe invoice with collection_method: send_invoice and the specified days_until_due.
  3. Adds line items for each operation type (amount in cents).
  4. Sets metadata: type: enterprise_usage, companyName.
  5. Finalizes the invoice (triggers Stripe to send the payment email).

Notification Events

EventTriggerChannelRecipient
enterprise.subscription.requestedUser submits enterprise requestIn-appSuper admins
enterprise.subscription.rejectedAdmin rejects requestIn-appRequesting user
enterprise.invoice.generatedMonthly invoice createdEmail + In-appBilling owner
enterprise.invoice.overdueInvoice past due dateEmail + In-appBilling owner
enterprise.access.blockedCompany access blockedIn-appBilling owner
enterprise.access.restoredAccess restored (payment or admin)In-appBilling owner

All notification handlers follow the fire-and-forget pattern (try-catch, never rethrow) and are implemented in BillingSystemNotificationListener. Event payload types accept companyId: string | null; listeners that show a human-readable scope label fall back to "A user (personal account)" (requester notifications) or the billing owner's email (UI labels) when the sub has no company.

Schema Changes

The enterprise billing system introduced the following schema additions:

New Enum: BillingMode

enum BillingMode {
PREPAID // Default: credits allocated upfront, consumed per operation
POSTPAID // Enterprise: unlimited access, invoiced monthly for AI usage
}

Plan Model

Added field:

billingMode BillingMode @default(PREPAID)

SubscriptionStatus Enum

Added values:

ValuePurpose
UNPAIDEnterprise subscription blocked for non-payment
PENDING_APPROVALEnterprise request awaiting admin approval

InvoiceStatus Enum

Added value:

ValuePurpose
OVERDUEEnterprise invoice past its due date

Invoice Model

Added fields:

FieldTypeDescription
companyIdString? @db.UuidEnterprise company (null for personal-scope invoices)
userIdString? @db.UuidBilling owner — set for every enterprise invoice, company-scoped or personal
billingPeriodStartDateTime?Start of the billing period (presence identifies an enterprise invoice)
billingPeriodEndDateTime?End of the billing period
dueDateDateTime?Payment deadline (5 days after generation)

Added unique constraint:

@@unique([companyId, billingPeriodStart, billingPeriodEnd], name: "unique_enterprise_invoice_period")

Added index:

@@index([companyId, status])

Frontend Integration

info

The enterprise plan is admin-managed. There is no self-service checkout flow for enterprise companies.

Detecting Enterprise Subscriptions

Check the company's active plan for billingMode: POSTPAID:

query GetCompanySubscription($companyId: ID!) {
companySubscription(companyId: $companyId) {
id
status
isActive
Plan {
name
billingMode
}
}
}

If Plan.billingMode === "POSTPAID":

  • Hide checkout and upgrade buttons -- enterprise companies do not buy credits or change plans through the UI.
  • Hide credit balance display -- enterprise companies have no credit balance. Show "Enterprise" or "Unlimited" instead.
  • Show usage dashboard -- display AI operation usage breakdown (see below).
  • Show invoice history -- list invoices with status and payment links.

Handling Blocked State

If status === "UNPAID":

  • Show a prominent banner indicating the company's access is blocked due to an overdue invoice.
  • Link to the Stripe-hosted payment page (from the invoice's stripeInvoiceUrl).
  • Disable all AI features (CV parsing, agent chat, matching, text rewrite).

Usage Breakdown (Admin View)

query EnterpriseUsage($companyId: ID!, $startDate: DateTime!, $endDate: DateTime!) {
adminEnterpriseUsageBreakdown(
companyId: $companyId
startDate: $startDate
endDate: $endDate
) {
companyName
lineItems {
operationType
operationCount
totalCost
totalInputTokens
totalOutputTokens
}
totalAmount
currency
}
}

Admin Panel Integration

The admin panel Enterprise tab (components/admin/payments/EnterpriseTab.tsx) surfaces:

  1. Subscription list — all enterprise subscriptions across all statuses, from adminEnterpriseSubscriptions. Rows with a null companyId are labeled Personal — <billing-owner-email> (no "Unnamed Company" fallback).
  2. Pending requests — status chip PENDING_APPROVAL with approve/reject actions.
  3. Create subscription — Company ID field is optional; leave blank for a personal enterprise sub tied to the billing-owner user. Plan ID + Billing Owner User ID are required.
  4. Usage previewEnterpriseUsageModal accepts companyId or userId via the parent's row scope; it passes either through to adminEnterpriseUsageBreakdown.
  5. On-demand invoice — Generate Invoice modal exposes both Company ID and Billing Owner User ID (XOR). The submit handler strips empty fields so only one is sent.
  6. Access control — Block/Restore buttons build a { companyId | userId } scope via getRowScope(row) and route through adminBlockEnterpriseAccess / adminRestoreEnterpriseAccess with EnterpriseAccessScopeInput. No more row.companyId as string casts.
Personal-sub labeling

getCompanyName(row) in EnterpriseTab.tsx returns "Personal — " + subscriber.email when row.company is null, using the already-fetched subscribedBy. This makes the list view clearly distinguish personal enterprise subs from company-scoped ones without a separate filter.

Comparison: Prepaid vs Post-Paid

AspectPrepaid (Standard)Post-Paid (Enterprise)
Billing modePREPAIDPOSTPAID
Payment timingBefore usageAfter usage (monthly)
Credit balanceRequired, checked before each operationNot used, balance = infinity
Plan limitsEnforced per plan configurationAll set to -1 (unlimited)
Subscription creationSelf-service via Stripe CheckoutAdmin-created or admin-approved request
Invoice generationAutomatic by Stripe (recurring)Cron job on 1st of month
Access blockingPAST_DUE (Stripe managed)UNPAID (custom overdue check)
BillingGate.pre_check()Checks subscription + plan limits + credit balanceChecks subscription status only
BillingGate.check_and_consume()Deducts credits + logs to AIOperationLogLogs to AIOperationLog only
BILLING_ENABLED flagRespects flag (skips checks when false)Always enforces UNPAID blocking