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 / ServiceWhen 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:
- Billing Units =
Math.ceil(duration_seconds / billing_interval_seconds)rounds up to nearest interval - Charge =
billing_units × rate_per_intervalexact decimal value - 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 Interval | Duration Calculation | Example (80-second call) |
|---|---|---|
60 (per minute) | Rounded up to nearest minute | 80s → 2 minutes (120s) |
45 (per 45 sec) | Rounded up to nearest 45 seconds | 80s → 90 seconds |
30 (per 30 sec) | Rounded up to nearest 30 seconds | 80s → 90 seconds |
15 (per 15 sec) | Rounded up to nearest 15 seconds | 80s → 90 seconds |
1 (per second) | Exact seconds (ceiling) | 80s → 80 seconds |
Key Rules:
billingUnitstores 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_pricingtier on the preset, OR cost_per_secto be configured on the preset
- A matching
- 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:
SuperAdmin creates
interval_pricingtiers 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" } ] }When a customer with
billingUnit: 15makes a call:- System finds the matching
interval_pricingtier (15 seconds) - Uses
cost_per_interval(0.5) as the rate per billing unit - Charges =
ceil(duration / 15) * 0.5
- System finds the matching
If no matching interval exists, falls back to:
cost_per_sec * billingUnitSecondsfor sub-minute intervalscost_per_minfor 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_minOrg Override for Interval Pricing:
- Orgs set
org_rate_per_secwhich 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_secin ServicePricing - Formula:
customer_rate = customer_rate_per_sec * billingUnitSeconds - NEW: Customers can also have
customer_interval_pricingfor 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_secondsmust be unique (no duplicates) interval_secondsmust be 1-3600cost_per_intervalmust be > 0 with max 6 decimal places
Data Models
CreditTransaction unified ledger for all credit movements:
| Field | Description |
|---|---|
transactionId | Unique nanoid |
transactionType | recharge, distribution, consumption, refund, adjustment |
fromType / fromId | Source entity (superadmin, organization, customer, system) |
toType / toId | Destination entity |
amount | Credits transferred (always positive) |
balanceAfter | Recipient balance after transaction |
orgUID | Tenant scoping |
serviceType | voice_call, web_agent (for consumption) |
serviceRef | contactUID/roomName for idempotency |
performedByUID | userUID who initiated |
metadata | Additional 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,totalCreditsConsumedbillingUnitbilling interval in seconds (default:60for 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_rateper-minute rate overridecustomer_rate_per_secper-second rate override (optional)customer_interval_pricingarray of per-interval rate overrides (optional)
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_secoverrides - ServicePricing per-customer
customer_rate,customer_rate_per_sec,customer_interval_pricingoverrides - 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:
creditBalancecurrent pool (wallet)totalCreditsReceivedlifetime from superadmintotalCreditsDistributedlifetime to customerstotalCreditsConsumedByServiceslifetime platform charges (reporting only)totalProfitRetainedlifetime 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:
| Event | Direction | Method |
|---|---|---|
| Superadmin grants credits to the org | increase | rechargeOrganization() |
| Org distributes credits to a customer | decrease | distributeToCustomer() |
| Org revokes credits from a customer | increase | revokeFromCustomer() |
| Customer purchases from the org pool | decrease | purchaseCredits() |
| Signup credits granted to a new customer | decrease | signup.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/*)
| Method | Path | What it does |
|---|---|---|
POST | /organizations/:orgId/recharge | Add credits to org pool |
GET | /credit-transactions | Platform-wide transaction history |
GET | /credit-summary | Platform-wide stats + org breakdown |
GET | /platform-rates | Current platform service rates |
Admin (/api/admin/*)
| Method | Path | What it does |
|---|---|---|
POST | /customers/:id/distribute-credits | Give credits to customer |
POST | /customers/:id/revoke-credits | Take credits back from customer |
GET | /credit-transactions | Org-scoped transaction history |
GET | /credit-summary | Org pool overview + customer breakdown |
GET | /service-pricing | Org rates (defaults + per-customer) |
PUT | /service-pricing/:serviceType | Update org default rate |
PUT | /customers/:id/service-pricing/:type | Set customer-specific rate |
DELETE | /customers/:id/service-pricing/:type | Remove customer override |
Customer (/api/credits/*)
| Method | Path | What it does |
|---|---|---|
GET | /balance | Own credit balance (JWT-based) |
GET | /transactions | Own transaction history |
GET | /service-rates | What 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 ServicePricingCreditService Methods
File: client_backend/src/services/credit.service.ts
| Method | Used by | What it does |
|---|---|---|
rechargeOrganization() | SuperAdmin | Adds credits to org pool |
distributeToCustomer() | Admin | Moves credits from org pool → customer |
revokeFromCustomer() | Admin | Moves 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 start | Checks if customer has enough credits at the org_rate for billing unit |
getCustomerBalance() | Customer API | Returns balance + totals |
getOrgCreditSummary() | Admin API | Org pool stats + customer breakdown |
getPlatformCreditSummary() | SuperAdmin API | Platform-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_secondsin 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_secondsandraw_duration_secondsin transaction metadata for audit
What consumeCredits writes:
// 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:
- Check for Interval Pricing Match If
interval_pricingis configured on the preset and a tier matches the customer'sbillingUnitSeconds, use that tier'scost_per_intervaldirectly - Fallback to Traditional Rates If no matching interval, use
cost_per_sec(for sub-minute) orcost_per_min(for per-minute)
For Interval Pricing Matches:
- Uses
cost_per_intervalfrom the matched tier as the base rate - If customer has
customer_interval_pricing[interval], use it directly (highest priority) - If customer has
customer_rate_per_sec, scales it:customer_rate_per_sec * billingUnitSeconds - If org has
superadmin_org_rate_per_sec, scales it:superadmin_org_rate_per_sec * billingUnitSeconds - If org has
org_rate_per_sec, scales it:org_rate_per_sec * billingUnitSeconds - Records
interval_pricing_labelin snapshot for audit trail
For Per-Minute Customers (billingUnitSeconds = 60, no interval_pricing match):
- Gets global superadmin rate from SystemConfig preset's
cost_per_min - Checks OrgConfigReplica for a
superadmin_org_rateoverride - Resolves
Effective_Superadmin_Rate=superadmin_org_rate?? globalcost_per_min - Resolves
org_rate= override.org_rate ?? Effective_Superadmin_Rate - Checks ServicePricing for
customer_rateoverride (if customerUID provided)
For Sub-Minute Customers (billingUnitSeconds < 60, no interval_pricing match):
- Gets global superadmin rate from SystemConfig preset's
cost_per_sec - Checks OrgConfigReplica for a
superadmin_org_rate_per_secoverride - Resolves
Effective_Superadmin_Rate_Per_Second=superadmin_org_rate_per_sec?? globalcost_per_sec - Resolves
org_rate= override.org_rate_per_sec ?? Effective_Superadmin_Rate_Per_Second - Checks ServicePricing for
customer_rate_per_secoverride (if customerUID provided)
Rate Snapshot (IRateSnapshot):
{
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:
- Superadmin must configure
cost_per_secon the preset (SystemConfig) - Org must have
org_rate_per_secset (OrgConfigReplica) or it defaults tocost_per_sec - 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_secorg_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_secondsin metadata - Filter transactions by billing unit in the admin credit management UI
- Legacy transactions without
billing_unit_secondsare 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_secconfigured
Part 2: Feature Gating System
⚠️ This section may be outdated. Last verified: 2025-02-14. The feature catalog has grown well beyond the 11 keys below —
AVAILABLE_FEATURESinclient_backend/src/types/index.tsnow defines ~60 features (CRM, commerce, WhatsApp/marketing channels, tickets, BYOK/BYON, the three native call entitlementsweb_call/phone_to_phone_call/ai_call, and more), andUI_FEATURE_MAPno longer lives intypes/index.ts. The two-axis model (entitlement vs RBAC), the dependency systems, bundles, role presets, and the grey-out policy are documented canonically inFEATURE-ENTITLEMENTS-AND-BUNDLES.mdandTEAM-RBAC.md— treat those as the source of truth. The notes below describe the original 11-feature model and therequireFeatureAccess/useFeature/FeatureGateplumbing, which still exists but now governs the larger catalog.
The 11 Features
| Feature Key | Description |
|---|---|
voice_campaign | Create, upload, start, stop campaigns |
web_agent | Web-based agent interactions |
call_analysis | Post-call sentiment/emotion analysis |
call_recording | Access call recordings |
csv_upload | Upload contact CSVs |
scheduled_campaigns | Schedule campaigns for future execution |
retry_logic | Auto-retry failed contacts |
tts_preview | Text-to-speech preview |
crm_sync | CRM integration sync |
export_data | Export campaign/contact data |
test_call | Make 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:
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:
router.post("/start-campaign/:uid", verifyJwt, requireFeatureAccess("voice_campaign"), handler);
router.post("/create-agent", verifyJwt, requireFeatureAccess("voice_campaign", "web_agent"), handler);Three layers:
- SuperAdmin bypasses all checks
- Admin org plan must include the feature (admin manages the org, no per-user check)
- 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:
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 assignmentcheckOrgFeature(orgId, feature)validates org plan only
Frontend Enforcement
useFeature Hook
File: src/hooks/useFeature.ts
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:
<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:
<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:
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:
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:
ToastContext.jsxprovides<ToastProvider>wrapping the entire app (outside AuthProvider)showToast(message, type)is a global function that works outside the React tree- Axios interceptors in
lib/axios.tsauto-toast:- Success: non-GET requests with
response.data.message→ green toast - Error: non-401 errors with
error.response.data.message→ red toast
- Success: non-GET requests with
- 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.
Sidebar Credit Balance (Customer)
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-creditspage - 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 balanceCustomer 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:
Preset Cards Each visible preset shown with:
- Org rate (per-minute and per-second)
- Interval pricing badges from superadmin config
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
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)
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:
{ 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.