Skip to main content

Wallet (Telephony Balance)

Audience: Engineering · Where in app: sidebar wallet pill; Packages → Wallet · Since: 2026-07

The "wallet" is not a new balance — it reuses SubscriptionModel.remaining_purchased_ai_credits (purchased AI-credit add-ons, 1 credit = $0.01). It funds telephony (number purchase, monthly rental, per-call Twilio cost), Agent K sell-side provider charges (per-step debits at call time incl. the LinkedIn/Unipile monthly seat renewal — see Agent K), and doubles as the overflow pool for AI usage.

Semantics

BalancePurposeCycle behavior
remaining_ai_creditsPlan credits — AI-only, never touched by telephonyReset to the plan allotment on renewal
remaining_purchased_ai_creditsThe wallet — telephony and AI fallbackCarries over — renewal jobs reset plan credits only
  • AI draw-down (AITokensCreditJob) spends plan credits first, then falls into the wallet.
  • Telephony and Agent K debits come only from the wallet. The balance may go negative (burst / mid-call) — carried forward and paid down by the next top-up; the UI renders negative balances in red rather than clamping.
  • Agent K debits go through AgentKWalletService + lib/agentKPricing.js (super-admin sell prices, settings row agent_k_provider_pricing; idempotent ai_credit_logs rows; unpriced steps free; failed provider calls unbilled).

Low-credit emails

Reworked in AITokensCreditJob:

  • "Running low" (ai-credits-low.html) fires once per allocation at the LOW_CREDITS_WARN_AT threshold, guarded by SubscriptionModel.ai_credits_low_notified_at (stamped only after the mail is queued; cleared on a new grant). Suppressed when auto-recharge is on — a recharge would re-arm the warning and turn it into a recurring mail.
  • "Depleted" is deliberately NOT suppressed by auto-recharge: hitting zero with it enabled means the recharge itself failed (declined/missing card).
  • Only one of the two sends in a given pass, never both.

AI-scrape gating

backend/app/lib/aiCredits.js — the single combined-balance rule (remaining_ai_credits + remaining_purchased_ai_credits > 0, active subscription required). Used by AutoScrapeJob (skip scheduled re-crawls for unfunded companies) and CompanyWebsiteController.scrape (reject manual "Add Website" scrapes with NO_CREDITS_MESSAGE); mirrors the balance test CompanyController applies for the UI so the scraper and the UI give the same verdict.

Gates

GET /internal/telephony/wallet/:company_id returns both flags:

  • has_telephony: wallet > 0 — premium gate for buy/rental/calls.
  • ai_available: plan > 0 || wallet > 0 — AI falls back to the wallet.
  • Plus markup_percent (see below) so ms-ai gates number purchase on the marked-up price and the frontend shows final prices.

Backend endpoints

backend/app/controllers/TelephonyWalletController.js, routes in backend/app/routes/internalTelephonyRoutes.js, ms-ai → backend auth via apiKeyAuth (BACKEND_API_KEY):

RouteWhat
GET /internal/telephony/wallet/:company_idBalance + gate flags + markup_percent
POST /internal/telephony/debitIdempotent debit: reserves idempotency_key (unique index on ai_credit_logs) before the $inc, so the 10-min call sweep / rental cron never double-charge
POST /internal/telephony/notifyRental suspend_warning (day 0/3/6) / released emails — backend owns templates + billing-user resolution

Markup. credits in the debit body is the raw Twilio cost; the wallet is charged base × (1 + markup% / 100) with sub-cent precision (deliberately not rounded to a whole cent). Markup comes from the settings key telephony_markup_percent (default 0); margin_credits is recorded on the ledger row for revenue reporting.

Billing-user resolution (_resolveBillingUser): sub-account owner if they have their own card → agency owner → subscription owner. Notify emails are best-effort — a mail failure never fails the request.

Crons

CronWhere / scheduleWhat
number-rental:meter-duems-ai src/elevenlabs/number-rental.scheduler.ts, daily 06:00 UTCMonthly number rental; idempotent on (number, period), catches up missed days
twilio-call-cost:meterms-ai, every 10 minSweeps each company subaccount's completed calls; idempotent on CallSid
number-rental:suspension-sweepms-ai, every 15 minSuspend → warn (day 0/3/6 via /notify) → release after 7 days
BalanceAutoRechargeJobbackend scheduler, every minuteTops up the wallet from the saved card when below balance_auto_recharge.threshold_credits

Kill switch for all ms-ai rental crons: NUMBER_RENTAL_CRON_ENABLED=false. Bring-your-own Twilio numbers are never metered. The suspension sweep intentionally runs slower than auto-recharge so a healthy card tops up before anything suspends.

Telephony charges are recorded in ms-ai cost-tracking via recordFlatUsdCharge (same mechanism as Agent K provider usage, separate providers for reconciliation) — see cost-tracking.

Auto-recharge

SubscriptionModel.balance_auto_recharge subdoc: { enabled, prompted, threshold_credits, addon_id }, managed via SubscriptionController (enabling requires an addon selected). Purchases with no card on file return a clear 402 (payment error, not a 500).

Frontend

  • Sidebar pillsrc/components/company/CompanyNavigationSidebar.vue (walletUsd, amber when low).
  • Wallet pagesrc/views/packages/Wallet.vue, GET /subscription/wallet-transactions. Type badges: buy_addon (Top-up), balance_auto_recharge (Auto-recharge), telephony_rental (Number rental), telephony_call (Call), telephony_debit (Telephony), plus Agent K line items: agent_k_apollo_search (lead search), agent_k_apollo_match (contact reveal), agent_k_predictleads (buying signals), agent_k_apify_sos (new businesses), agent_k_unipile_connect (LinkedIn connected), agent_k_unipile_renewal (LinkedIn monthly). Negative balances and debit rows render red.
  • Tabssrc/components/packages/PackagesTabs.vue: Wallet / Add-ons / Payment Methods.
  • Accesssrc/composables/useBalanceAccess.js now grants canUseTelephony and canSelfServeTopup to everyone, including agency sub-accounts (full self-service; the old agency-funded restriction is lifted). isAgencySubAccount remains only for copy/tab tailoring — subscription & invoices stay agency-owned.