Skip to content

Credit Management & Feature Gating System ​

Oktivo platform documentation for new developers. Covers the 3-tier credit system and hierarchical feature access control.


Architecture Overview ​


Part 1: Credit Management ​

How Credits Flow ​

SuperAdmin ──recharge──▶ Organization Pool ──distribute──▶ Customer Balance
                              │                                  │
                              │ platform rate                    │ org rate
                              │ (per-min or per-sec)             │ (per-min or per-sec)
                              │                                  │
                              └──────────┐    ┌──────────────────┘
                                         ▼    ▼
                                    Voice Call / Service

When a customer uses a service (e.g. voice call):

  • Customer balance decreases by org rate (from rate snapshot captured at call start)
  • Org pool decreases by effective superadmin rate (from rate snapshot)
  • Org retains the spread as profit (org_rate − effective_superadmin_rate) × units

Charge Calculation Formula:

  1. Billing Units = Math.ceil(duration_seconds / billing_interval_seconds) rounds up to nearest interval
  2. Charge = billing_units × rate_per_interval exact decimal value
  3. Final Charge = rounded to 2 decimal places (e.g., 0.6, 2.4, 1.2 credits)

Credit balances support decimals, allowing accurate billing that matches the configured rate:

  • 10s call at 0.6/30s → 1 unit × 0.6 = 0.6 credits
  • 102s call at 0.6/30s → 4 units × 0.6 = 2.4 credits
  • 39s call at 0.6/30s → 2 units × 0.6 = 1.2 credits

Billing Units (Dynamic Second-Based Pricing) ​

Customers can be configured with flexible billing intervals in seconds:

Billing IntervalDuration CalculationExample (80-second call)
60 (per minute)Rounded up to nearest minute80s → 2 minutes (120s)
45 (per 45 sec)Rounded up to nearest 45 seconds80s → 90 seconds
30 (per 30 sec)Rounded up to nearest 30 seconds80s → 90 seconds
15 (per 15 sec)Rounded up to nearest 15 seconds80s → 90 seconds
1 (per second)Exact seconds (ceiling)80s → 80 seconds

Key Rules:

  • billingUnit stores the billing interval in seconds (1, 15, 30, 45, 60, or any custom value 1-3600)
  • Legacy string values ('per_minute', 'per_second') are supported for backward compatibility
  • Organizations can change a customer's billing unit at any time (affects future calls only)
  • Existing/legacy customers default to 60 (per minute)
  • Sub-minute billing (< 60 seconds) requires either:
    • A matching interval_pricing tier on the preset, OR
    • cost_per_sec to be configured on the preset
  • Rates are validated against floors within the same billing unit type only

Interval Pricing (Custom Billing Tiers) ​

SuperAdmins can define custom billing intervals with specific costs per preset. This allows different rates for different billing intervals (e.g., 10 seconds = 0.3 credits, 15 seconds = 0.5 credits, 47 seconds = 1.2 credits).

How It Works:

  1. SuperAdmin creates interval_pricing tiers on a preset in SystemConfig:

    json
    {
      "preset_id": "oktivo_balanced",
      "cost_per_min": 2.0,
      "cost_per_sec": 0.04,
      "interval_pricing": [
        { "interval_seconds": 10, "cost_per_interval": 0.3, "label": "Economy" },
        { "interval_seconds": 15, "cost_per_interval": 0.5, "label": "Standard" },
        { "interval_seconds": 30, "cost_per_interval": 0.9, "label": "Premium" },
        { "interval_seconds": 47, "cost_per_interval": 1.2, "label": "Custom" }
      ]
    }
  2. When a customer with billingUnit: 15 makes a call:

    • System finds the matching interval_pricing tier (15 seconds)
    • Uses cost_per_interval (0.5) as the rate per billing unit
    • Charges = ceil(duration / 15) * 0.5
  3. If no matching interval exists, falls back to:

    • cost_per_sec * billingUnitSeconds for sub-minute intervals
    • cost_per_min for per-minute intervals

Rate Hierarchy with Interval Pricing:

1. Check for matching interval_pricing tier on preset
   ↓ if found
2. Use cost_per_interval directly (already per-interval)
   ↓ if not found, fall back to
3. For sub-minute: cost_per_sec (scaled by interval)
   For per-minute: cost_per_min

Org Override for Interval Pricing:

  • Orgs set org_rate_per_sec which is scaled by the interval
  • Formula: org_rate = org_rate_per_sec * billingUnitSeconds
  • Example: org_rate_per_sec = 0.06, billingUnit = 15 → org_rate = 0.9

Customer Override for Interval Pricing:

  • Customers can have customer_rate_per_sec in ServicePricing
  • Formula: customer_rate = customer_rate_per_sec * billingUnitSeconds
  • NEW: Customers can also have customer_interval_pricing for direct interval rate overrides
  • Direct interval overrides take priority over calculated rates

Rate Resolution Priority for Interval Pricing:

1. customer_interval_pricing[interval].cost_per_interval (direct override - highest priority)
    ↓ if not found
2. customer_rate_per_sec × interval_seconds (calculated from per-second rate)
    ↓ if not found
3. org_rate_per_sec × interval_seconds (calculated from org rate)
    ↓ if not found
4. interval_pricing[interval].cost_per_interval (superadmin base - fallback)

Validation Rules:

  • Max 20 interval_pricing entries per preset
  • Each interval_seconds must be unique (no duplicates)
  • interval_seconds must be 1-3600
  • cost_per_interval must be > 0 with max 6 decimal places

Data Models ​

CreditTransaction unified ledger for all credit movements:

FieldDescription
transactionIdUnique nanoid
transactionTyperecharge, distribution, consumption, refund, adjustment
fromType / fromIdSource entity (superadmin, organization, customer, system)
toType / toIdDestination entity
amountCredits transferred (always positive)
balanceAfterRecipient balance after transaction
orgUIDTenant scoping
serviceTypevoice_call, web_agent (for consumption)
serviceRefcontactUID/roomName for idempotency
performedByUIDuserUID who initiated
metadataAdditional context (phone, billing_unit, raw_duration_seconds, billingMinutes, agentId for consumption txns)

File: client_backend/src/models/CreditTransaction.ts

User model credit fields:

  • creditBalance, totalCreditsReceived, totalCreditsConsumed
  • billingUnit billing interval in seconds (default: 60 for per-minute). Supports numeric values (1, 15, 30, 45, 60, etc.) and legacy strings ('per_minute', 'per_second') for backward compatibility. Organizations can update this value.

ServicePricing per-customer rate configuration:

The ServicePricing model stores customer-specific rate overrides:

  • customer_rate per-minute rate override
  • customer_rate_per_sec per-second rate override (optional)
  • customer_interval_pricing array of per-interval rate overrides (optional)
typescript
interface ICustomerIntervalPricing {
  interval_seconds: number;
  cost_per_interval: number;
}

interface IServicePricing {
  orgUID: string;
  customerUID: string;
  preset_id: string;
  customer_rate: number;
  customer_rate_per_sec?: number;
  customer_interval_pricing?: ICustomerIntervalPricing[];
}

Rate configuration hierarchy:

  • SystemConfig presets global cost_per_min, cost_per_sec, interval_pricing (managed by superadmin)
  • OrgConfigReplica per-org org_rate, org_rate_per_sec overrides
  • ServicePricing per-customer customer_rate, customer_rate_per_sec, customer_interval_pricing overrides
  • Rate snapshots captured at call start for billing isolation during in-progress calls

See SERVICE-PRICING.md for detailed documentation on customer rate overrides.

The new rate resolution order:

Per-Minute (default):superadmin_org_rate (if set for that org/preset) → global cost_per_min from SystemConfig preset → this becomes the Effective_Superadmin_Rate floor. The org_rate must always be >= Effective_Superadmin_Rate.

Per-Second:superadmin_org_rate_per_sec (if set) → global cost_per_sec from SystemConfig preset → this becomes the Effective_Superadmin_Rate_Per_Second floor. The org_rate_per_sec must always be >= Effective_Superadmin_Rate_Per_Second.

Organization model credit fields:

  • creditBalance current pool (wallet)
  • totalCreditsReceived lifetime from superadmin
  • totalCreditsDistributed lifetime to customers
  • totalCreditsConsumedByServices lifetime platform charges (reporting only)
  • totalProfitRetained lifetime profit, customer charges − platform charges (reporting only)

User model credit fields:

  • creditBalance, totalCreditsReceived, totalCreditsConsumed

Wallet vs reporting fields ​

Only Organization.creditBalance and User.creditBalance are wallets real spendable balances. The total* fields are cumulative counters for dashboards and audit; they never gate an operation.

An organization's creditBalance moves only on wallet transfers:

EventDirectionMethod
Superadmin grants credits to the orgincreaserechargeOrganization()
Org distributes credits to a customerdecreasedistributeToCustomer()
Org revokes credits from a customerincreaserevokeFromCustomer()
Customer purchases from the org pooldecreasepurchaseCredits()
Signup credits granted to a new customerdecreasesignup.service.ts

Calls never debit the organization wallet. distributeToCustomer() already decrements Organization.creditBalance when credits are handed to a customer; debiting the same field again at call time would double-count the same credits. Call consumption therefore debits only User.creditBalance, while the org's wholesale cost and margin continue to accumulate in totalCreditsConsumedByServices and totalProfitRetained for reporting.

API Endpoints ​

SuperAdmin (/api/superadmin/*) ​

MethodPathWhat it does
POST/organizations/:orgId/rechargeAdd credits to org pool
GET/credit-transactionsPlatform-wide transaction history
GET/credit-summaryPlatform-wide stats + org breakdown
GET/platform-ratesCurrent platform service rates

Admin (/api/admin/*) ​

MethodPathWhat it does
POST/customers/:id/distribute-creditsGive credits to customer
POST/customers/:id/revoke-creditsTake credits back from customer
GET/credit-transactionsOrg-scoped transaction history
GET/credit-summaryOrg pool overview + customer breakdown
GET/service-pricingOrg rates (defaults + per-customer)
PUT/service-pricing/:serviceTypeUpdate org default rate
PUT/customers/:id/service-pricing/:typeSet customer-specific rate
DELETE/customers/:id/service-pricing/:typeRemove customer override

Customer (/api/credits/*) ​

MethodPathWhat it does
GET/balanceOwn credit balance (JWT-based)
GET/transactionsOwn transaction history
GET/service-ratesWhat services cost for this customer

Backend Code Map ​

client_backend/src/
├── models/
│   ├── CreditTransaction.ts        # Unified transaction ledger
│   ├── OrgConfigReplica.ts          # Per-org rate overrides and visibility (includes per-sec rates)
│   └── SystemConfig.ts              # Platform-wide presets with cost_per_min and cost_per_sec
├── repositories/
│   ├── creditTransaction.repository.ts
│   └── orgConfigReplica.repository.ts
├── services/
│   ├── credit.service.ts            # Core credit operations (billing-unit-aware)
│   └── orgConfigReplica.service.ts  # Org rate management, cascade, floor enforcement
├── utils/
│   ├── rateResolver.ts              # captureRateSnapshot(orgUID, presetId, customerUID)
│   ├── duration.utils.ts            # computeCallDurations() with billing unit support
│   └── rateValidation.utils.ts      # Floor constraint validation per billing unit
├── types/
│   ├── billing.types.ts             # BillingUnit type, validation helpers, precision constants
│   └── index.ts                     # TransactionType, EntityType, etc.
├── controllers/
│   ├── credit.controller.ts         # Credit API handlers
│   └── orgConfigReplica.controller.ts # Org config and superadmin rate handlers
├── routes/
│   ├── credit.routes.ts             # Customer credit routes
│   ├── orgConfigReplica.routes.ts   # Org-admin + superadmin rate routes
│   ├── superadmin.routes.ts         # Includes recharge + platform stats
│   └── admin.routes.ts              # Includes distribute/revoke
└── scripts/
    └── migrate-to-org-config-replica.ts # Migration from ServicePricing

CreditService Methods ​

File: client_backend/src/services/credit.service.ts

MethodUsed byWhat it does
rechargeOrganization()SuperAdminAdds credits to org pool
distributeToCustomer()AdminMoves credits from org pool → customer
revokeFromCustomer()AdminMoves credits from customer → org pool
consumeCredits({ presetId, rateSnapshot, units, ... })System (call end)Debits only the customer at org_rate. Records the org's superadmin_rate cost and margin as counters the org wallet is not debited. Respects billing_unit from snapshot
checkBalance({ presetId, rateSnapshot, estimatedUnits, ... })Campaign startChecks if customer has enough credits at the org_rate for billing unit
getCustomerBalance()Customer APIReturns balance + totals
getOrgCreditSummary()Admin APIOrg pool stats + customer breakdown
getPlatformCreditSummary()SuperAdmin APIPlatform-wide stats + org breakdown

All write operations use MongoDB sessions for atomicity. Negative balance is prevented using $inc with { creditBalance: { $gte: amount } }.

Billing Unit Handling in consumeCredits:

  • Validates billing_unit_seconds in rate snapshot
  • For per-minute (60s): converts duration seconds to minutes for rate multiplication
  • For sub-minute intervals (1-59s): uses billing units calculated by ceil(duration / billingUnitSeconds)
  • Records billing_unit_seconds and raw_duration_seconds in transaction metadata for audit

What consumeCredits writes:

ts
// Customer wallet debited, with negative-balance prevention
User.findOneAndUpdate(
  { userUID: customerUID, creditBalance: { $gte: customerCharge } },
  { $inc: { creditBalance: -customerCharge, totalCreditsConsumed: customerCharge } }
);

// Organization counters only, wallet untouched
Organization.findOneAndUpdate(
  { orgUID },
  { $inc: {
      totalCreditsConsumedByServices: orgCharge,
      totalProfitRetained: customerCharge - orgCharge,
  } }
);

ConsumeResult.orgCharged is the recorded wholesale cost, not a wallet debit. Log lines of the form Credits deducted: customer=X, org=Y report Y as that recorded cost.

The org side deliberately has no creditBalance: { $gte: orgCharge } filter. When it did, a low org balance made the whole session.withTransaction roll back — undoing the customer's deduction as well so a completed call ended up billed to nobody. The same rule is applied in scripts/recorded-message-billing.ts and scripts/billing-reconciliation.ts, which previously debited the org wallet too.

Regression cover: __tests__/end-to-end-billing-flow.integration.test.ts asserts the org wallet is unchanged by a call while the counters still accumulate.

Rate Resolution (Billing-Unit-Aware System) ​

The captureRateSnapshot(orgUID, presetId, customerUID) utility at src/utils/rateResolver.ts resolves rates at call start:

Resolution Priority:

  1. Check for Interval Pricing Match If interval_pricing is configured on the preset and a tier matches the customer's billingUnitSeconds, use that tier's cost_per_interval directly
  2. Fallback to Traditional Rates If no matching interval, use cost_per_sec (for sub-minute) or cost_per_min (for per-minute)

For Interval Pricing Matches:

  1. Uses cost_per_interval from the matched tier as the base rate
  2. If customer has customer_interval_pricing[interval], use it directly (highest priority)
  3. If customer has customer_rate_per_sec, scales it: customer_rate_per_sec * billingUnitSeconds
  4. If org has superadmin_org_rate_per_sec, scales it: superadmin_org_rate_per_sec * billingUnitSeconds
  5. If org has org_rate_per_sec, scales it: org_rate_per_sec * billingUnitSeconds
  6. Records interval_pricing_label in snapshot for audit trail

For Per-Minute Customers (billingUnitSeconds = 60, no interval_pricing match):

  1. Gets global superadmin rate from SystemConfig preset's cost_per_min
  2. Checks OrgConfigReplica for a superadmin_org_rate override
  3. Resolves Effective_Superadmin_Rate = superadmin_org_rate ?? global cost_per_min
  4. Resolves org_rate = override.org_rate ?? Effective_Superadmin_Rate
  5. Checks ServicePricing for customer_rate override (if customerUID provided)

For Sub-Minute Customers (billingUnitSeconds < 60, no interval_pricing match):

  1. Gets global superadmin rate from SystemConfig preset's cost_per_sec
  2. Checks OrgConfigReplica for a superadmin_org_rate_per_sec override
  3. Resolves Effective_Superadmin_Rate_Per_Second = superadmin_org_rate_per_sec ?? global cost_per_sec
  4. Resolves org_rate = override.org_rate_per_sec ?? Effective_Superadmin_Rate_Per_Second
  5. Checks ServicePricing for customer_rate_per_sec override (if customerUID provided)

Rate Snapshot (IRateSnapshot):

typescript
{
  preset_id: string;
  billing_unit: BillingUnit;                     // Legacy format for compatibility
  billing_unit_seconds: number;                  // Billing interval in seconds (1, 15, 30, 45, 60, etc.)
  org_rate: number;                              // Rate per billing unit
  superadmin_rate: number;                       // Effective superadmin rate per billing unit
  superadmin_rate_source: 'per_org' | 'global' | 'interval_pricing';
  rate_source: 'customer_override' | 'org_override' | 'default' | 'interval_pricing';
  interval_pricing_label?: string;               // Label from interval_pricing tier (if used)
  captured_at: string;                           // ISO timestamp
}

The snapshot is captured once at call start and passed to consumeCredits at call end. This ensures billing isolation rate changes during a call don't affect in-progress billing.

Integration Points ​

Campaign start guard before a campaign starts, CreditService.checkBalance() verifies the customer has enough credits using a rate snapshot. If not, the API returns 403 with insufficient credits error.

Call completion billing when a call ends, CreditService.consumeCredits() is called with the rate snapshot (captured at call start) and the call duration. The billing unit from the snapshot determines how duration is computed:

  • Per-minute (60s): duration rounded up to nearest minute, then converted to minutes for rate multiplication
  • Sub-minute (1-59s): billing units = ceil(duration / billingUnitSeconds), then multiplied by per-second rate

The service deducts the customer at the org_rate and the org at the superadmin_rate (from snapshot), in a single MongoDB session. The transaction stores metadata with { preset_id, billing_unit_seconds, raw_duration_seconds, org_rate, superadmin_rate, superadmin_rate_source, units, customer_charge, org_charge, margin } for audit.

Test call billing test calls (isTestCall: true) are free. The webhook controller skips credit deduction for test calls.

Rate snapshot capture captureRateSnapshot(orgUID, presetId) is called at call dispatch time. The snapshot is stored alongside call metadata (Redis or in-memory) and used at call end for billing. This provides snapshot isolation rate changes during a call never affect billing.

Negative balance prevention all deductions use $inc with a { creditBalance: { $gte: amount } } filter. If the balance is too low, the update matches zero documents and the operation fails gracefully.

Per-org preferential pricing superadmins can set superadmin_org_rate (per-minute) or superadmin_org_rate_per_sec (per-second) per org/preset to offer preferential rates. The org is charged at this rate instead of the global rate. This doesn't affect what the org charges their customers (org_rate).

Sub-Minute Billing Configuration ​

Prerequisites:

  1. Superadmin must configure cost_per_sec on the preset (SystemConfig)
  2. Org must have org_rate_per_sec set (OrgConfigReplica) or it defaults to cost_per_sec
  3. Customer must be created with billingUnit: 'per_second'

Rate Hierarchy for Per-Second:

customer_rate_per_sec (ServicePricing, if set)
        ↓ fallback
org_rate_per_sec (OrgConfigReplica)
        ↓ fallback
superadmin_org_rate_per_sec (OrgConfigReplica, if set)
        ↓ fallback
cost_per_sec (SystemConfig preset)

Floor Constraints:

  • customer_rate_per_sec >= org_rate_per_sec
  • org_rate_per_sec >= Effective_Superadmin_Rate_Per_Second
  • No cross-unit comparison per-second rates only validated against per-second floors

Rate Cascade: When cost_per_sec or superadmin_org_rate_per_sec increases, any org_rate_per_sec below the new floor is automatically raised to match. Same cascade logic as per-minute rates.

Transaction History:

  • Consumption transactions include billing_unit_seconds in metadata
  • Filter transactions by billing unit in the admin credit management UI
  • Legacy transactions without billing_unit_seconds are treated as per-minute (60s)

Changing Customer Billing Unit:

  • Organizations can change a customer's billing unit at any time via the Admin API
  • Changes affect future calls only in-progress calls use the rate snapshot captured at call start
  • When switching to sub-minute billing (< 60s), the system validates that all allowed presets have cost_per_sec configured

Part 2: Feature Gating System ​

The 11 Features ​

Feature KeyDescription
voice_campaignCreate, upload, start, stop campaigns
web_agentWeb-based agent interactions
call_analysisPost-call sentiment/emotion analysis
call_recordingAccess call recordings
csv_uploadUpload contact CSVs
scheduled_campaignsSchedule campaigns for future execution
retry_logicAuto-retry failed contacts
tts_previewText-to-speech preview
crm_syncCRM integration sync
export_dataExport campaign/contact data
test_callMake test calls to agents

Defined in client_backend/src/types/index.ts → AVAILABLE_FEATURES.

FEATURE_MAP Action-to-Feature Mapping ​

Routes use FEATURE_MAP to declare which feature(s) an action requires:

typescript
export const FEATURE_MAP = {
  uploadCampaign:    ["voice_campaign"],
  startCampaign:     ["voice_campaign"],
  stopCampaign:      ["voice_campaign"],
  listCampaigns:     ["voice_campaign"],
  scheduleCampaign:  ["scheduled_campaigns"],
  retryCampaign:     ["retry_logic"],
  createAgent:       ["voice_campaign", "web_agent"],  // OR logic
  manageAgent:       ["voice_campaign", "web_agent"],
  testAgent:         ["test_call"],
  csvUpload:         ["csv_upload"],
  exportData:        ["export_data"],
  callRecording:     ["call_recording"],
  callAnalysis:      ["call_analysis"],
  ttsPreview:        ["tts_preview"],
  crmSync:           ["crm_sync"],
};

When multiple features are listed, the user needs at least one (OR logic).

Backend Enforcement ​

requireFeatureAccess Middleware (HTTP layer) ​

File: client_backend/src/middleware/feature.middleware.ts

Usage in routes:

typescript
router.post("/start-campaign/:uid", verifyJwt, requireFeatureAccess("voice_campaign"), handler);
router.post("/create-agent", verifyJwt, requireFeatureAccess("voice_campaign", "web_agent"), handler);

Three layers:

  1. SuperAdmin bypasses all checks
  2. Admin org plan must include the feature (admin manages the org, no per-user check)
  3. Customer org plan AND user-level features must both include it

featureAccessService (Internal / service layer) ​

File: client_backend/src/services/featureAccess.service.ts

Used by internal services (calling service, scheduler) that don't go through HTTP:

typescript
const result = await featureAccessService.checkCustomerFeature(userId, orgId, "voice_campaign");
if (!result.allowed) throw new Error(result.reason);

Two methods:

  • checkCustomerFeature(userId, orgId, feature) validates org plan + user assignment
  • checkOrgFeature(orgId, feature) validates org plan only

Frontend Enforcement ​

useFeature Hook ​

File: src/hooks/useFeature.ts

typescript
const { hasFeature, hasAnyFeature, hasAllFeatures, availableFeatures } = useFeature();

hasFeature("voice_campaign");           // boolean
hasAnyFeature(["voice_campaign", "web_agent"]); // OR
availableFeatures();                    // string[]

Role-based logic:

  • SuperAdmin → always true, all features available
  • Admin → checks organization.plan.features
  • Customer → checks intersection of org features AND user features (both must include it)

FeatureGate Component ​

File: src/components/FeatureGate.tsx

Conditionally renders children based on feature access:

jsx
<FeatureGate features={["voice_campaign"]}>
  <CampaignButton />
</FeatureGate>

<FeatureGate features={["export_data"]} fallback={<UpgradeBanner />}>
  <ExportButton />
</FeatureGate>

FeatureRoute Component ​

File: src/components/auth/FeatureRoute.tsx

Route-level gate redirects to /dashboard if user lacks access:

jsx
<Route path="/campaigns" element={
  <FeatureRoute features={["voice_campaign"]}><CampaignsPage /></FeatureRoute>
} />

UI_FEATURE_MAP Sidebar Gating ​

Maps sidebar items to required features. Items always render but are greyed out and non-clickable when the user lacks access:

typescript
export const UI_FEATURE_MAP = {
  campaigns:  [FEATURES.VOICE_CAMPAIGN],
  agents:     [FEATURES.VOICE_CAMPAIGN, FEATURES.WEB_AGENT],
  upload:     [FEATURES.VOICE_CAMPAIGN, FEATURES.CSV_UPLOAD],
  addCredits: [],  // always visible
  myNumbers:  [FEATURES.VOICE_CAMPAIGN],
  testCall:   [FEATURES.TEST_CALL],
  // ...
};

Feature Validation & Cascade ​

validateFeatureArray() Utility ​

File: client_backend/src/utils/feature.utils.ts

Validates that a features array contains only valid feature strings:

typescript
const result = validateFeatureArray(req.body.features);
if (!result.valid) return res.status(400).json({ success: false, message: result.error });

Used by both superadmin.controller.updateOrganization and admin.controller.updateCustomer.

Cascade Removal ​

When a SuperAdmin removes a feature from an org, all customers in that org who had that feature get it removed automatically:

This prevents customers from retaining features their org no longer has.

Org-Boundary Check ​

When an Admin assigns features to a customer, the backend verifies every feature exists in the org plan. If a feature is not in the org plan, the request is rejected with 403.

Feature Management UI ​

FeatureModal Shared Component ​

File: src/components/shared/FeatureModal.jsx

Reusable modal with toggleable chips for feature selection:

  • Select All / Deselect All
  • Chip-based toggle UI (selected = blue border + blue bg, unselected = grey)
  • Loading state on save, inline error display
  • Used in both OrgManagement (SuperAdmin) and CustomerManagement (Admin)

In OrgManagement SuperAdmin clicks "Features" on an org row → modal shows all 11 features → saves via PATCH /api/superadmin/organizations/:id.

In CustomerManagement Admin clicks "Features" on a customer row → modal shows only the org's features (not all 11) → saves via PATCH /api/admin/customers/:id.


Part 3: Frontend Architecture ​

Toast Notification System ​

A global toast system that auto-shows API messages without per-component code.

How it works:

  1. ToastContext.jsx provides <ToastProvider> wrapping the entire app (outside AuthProvider)
  2. showToast(message, type) is a global function that works outside the React tree
  3. Axios interceptors in lib/axios.ts auto-toast:
    • Success: non-GET requests with response.data.message → green toast
    • Error: non-401 errors with error.response.data.message → red toast
  4. For validation/non-API messages, call showToast() directly

Toast types: success (green), error (red), warning (amber), info (blue).

Auto-dismiss: success 3s, error 5s, warning 4s, info 3s.

Adding a new API endpoint? If it returns { success: true, message: "..." }, the toast shows automatically. Zero frontend code needed.

Customers see a compact credit card in the sidebar above the logout button:

  • Shows wallet icon + "Credits" label + formatted balance (monospace font)
  • Links to /add-credits page
  • When sidebar is collapsed, shows just the wallet icon
  • Balance refreshes on every page load via refreshUser()

Frontend Code Map ​

src/
├── context/
│   └── ToastContext.jsx          # Global toast provider + showToast()
├── lib/
│   └── axios.ts                  # Axios instance + auto-toast interceptors
├── hooks/
│   └── useFeature.ts             # Feature access hook + FEATURES + UI_FEATURE_MAP
├── components/
│   ├── FeatureGate.tsx           # Conditional render by feature
│   ├── auth/FeatureRoute.tsx     # Route-level feature redirect
│   ├── shared/FeatureModal.jsx   # Feature selection modal (chips)
│   └── features/
│       └── CustomerPricingOverrides.tsx  # Customer rate overrides with interval pricing
├── pages/
│   ├── superadmin/
│   │   ├── OrgManagement.jsx     # Org CRUD + feature modal
│   │   └── CreditOverview.jsx    # Platform-wide credit stats
│   ├── admin/
│   │   ├── CustomerManagement.jsx # Customer CRUD + feature modal + billing unit
│   │   ├── CreditManagement.jsx  # Org credit distribution
│   │   ├── ConfigManagementPage.tsx # Tabs: System Config, Pricing, Customer Overrides
│   │   └── ServicePricing.jsx    # Rate configuration (redirects to Config)
│   └── AddCredits.jsx            # Customer credit balance + history
└── MainLayout.jsx                # Sidebar with feature gating + credit balance

Customer Pricing Overrides UI ​

Location: /admin/config → "Customer Overrides" tab

Component: src/components/features/CustomerPricingOverrides.tsx

This component allows org admins to set per-customer rates:

  1. Preset Cards Each visible preset shown with:

    • Org rate (per-minute and per-second)
    • Interval pricing badges from superadmin config
  2. Customer Overrides Table Inline editable columns:

    • Customer Name, Email
    • Rate (credits/min)
    • Rate (credits/sec) shown if per-second pricing enabled
    • Interval Rates badges with direct editing capability
  3. Interval Rate Editing:

    • Click edit icon to enable inline editing
    • Each interval shows an input field
    • Leave empty to use calculated rate (per-second × interval)
    • Enter value to set direct override (highest priority)
  4. Badge Color Coding:

    • Blue: Direct customer interval override
    • Grey: Calculated from customer's per-second rate
    • Outline: Using org or base rate

See SERVICE-PRICING.md for complete documentation.


Part 4: Edge Cases & Safety ​

Atomicity ​

All credit write operations (recharge, distribute, revoke, consume) use MongoDB sessions with startSession() + withTransaction(). If any step fails, the entire operation rolls back.

Idempotency ​

CreditTransaction.serviceRef stores the callId or campaignId. Before creating a consumption transaction, the service checks if a transaction with that serviceRef already exists. This prevents double-billing on webhook retries.

Negative Balance Prevention ​

Deductions use an atomic findOneAndUpdate with a balance floor:

typescript
{ creditBalance: { $gte: amount } }

If the balance is insufficient, the update matches zero documents and the operation returns a clear error instead of going negative.

Race Conditions ​

MongoDB sessions + the $gte guard handle concurrent deductions. Two simultaneous calls trying to consume the last credits will result in one succeeding and one failing never both succeeding.

Feature Cascade Safety ​

When org features are removed, the cascade to customers uses $pull (idempotent). Running it multiple times has no side effect. The affectedCustomers count in the response tells the admin how many customers were impacted.

Suspended Orgs ​

Both the feature middleware and featureAccessService check org.status === "suspended" before allowing any feature access. Suspended orgs are fully locked out regardless of feature assignments.

Last updated: