Ringvoo system notes (living doc)

This document is meant for you (the builder). It captures the current system behavior and the “why” behind decisions that are easy to forget: billing, dashboard billing portal + PDF invoices, wallet debits + provisional settlement, Twilio pricing, SMS (Twilio Messaging) pricing & billing, max call duration, Stripe wallet funding (Checkout + webhooks + auto top-up), Stripe customer emails / receipt copy (API + Dashboard), super-admin Stripe refunds, virtual number purchase (Stripe subscription + Twilio provisioning), and all environment variables. Stripe card refunds initiated from the super-admin panel debit the wallet via refund.created / charge.refunded webhooks — see Stripe card refunds (super-admin API + webhooks).

It is intentionally detailed and code-adjacent. When something changes, update the relevant section and include the commit/PR that introduced it.

Table of contents

Goals and design constraints

Ringvoo is a Twilio-powered calling product with a wallet-based billing model. The key constraints we’ve designed around:

  • Twilio call pricing is not always immediately available at the moment a call ends; webhooks can arrive without Price, and Twilio REST may populate price slightly later.
  • Users can spam call attempts (double-click, rapid redial, multiple tabs). We must not allow them to exceed their wallet balance.
  • We must avoid accidental subsidies: refunds should not give money back when Twilio actually charged us (carrier leg billed).
  • Everything must be idempotent because webhooks retry and REST resync jobs can re-run.
  • Voice admission without CallHold rows: new calls are blocked when Wallet.balance ≤ 0; when Wallet.balance > 0, calls are allowed and PSTN talk time is capped with <Dial timeLimit> (computeVoiceDialTimeLimitSeconds). In low balance mode, the time limit uses a budget-based seconds estimate plus a configurable step list (RINGVOO_LOW_BALANCE_TIME_LIMIT_STEPS_SECONDS). CallSession rows still exist for settlement/idempotency, but pre-call liquidity no longer subtracts concurrent-session exposure (exposure is treated as 0 in evaluateVoiceCallLiquidity). Org members add a monthly quota layer on top of the org wallet.

Current capabilities

As of today, the repo implements:

  • Frontend marketing site + dashboard pages (notably the Phone/Dialer experience).
  • Support/contact flows: dashboard support page, marketing contact page, Resend-powered support inbox routing, and user-facing support reply handling.
  • Contacts management in the dashboard, including CRUD APIs, CSV template support, and contact-aware UI flows.
  • Twilio Voice SDK token generation for browser calling.
  • Outbound call TwiML generation with max call duration enforcement.
  • Inbound call TwiML routing: called Twilio number maps to user → dial browser client.
  • Wallet system + transactions.
  • Voice admission without CallHold rows: no credit extension (computeVoiceCreditLimitUsd is 0); lastPurchaseAmount is still tracked for low balance mode thresholds (RINGVOO_LOW_BALANCE_THRESHOLD_PERCENT); CallSession supports settlement; org members also have a monthly voice quota (OrgMemberVoiceQuota / OrgMemberVoiceQuotaUsage).
  • Call status callback handler that persists calls and settles wallet debits/refunds; CallSettlement for idempotent audit; provisional estimated debit when Twilio price is missing on completed.
  • A delayed “cost sync” job system to settle completed calls whose Twilio price arrives late.
  • Stripe: USD wallet top-up via Checkout (mode: payment); optional save card without charging via Checkout (mode: setup); webhooks credit the wallet (personal or organization wallet per active context). Personal charges use User.stripeCustomerId; organization wallet top-ups and org setup sessions use Organization.stripeCustomerId (dedicated org payer in Stripe — see Organization wallet vs Stripe Customer). Tax ID: User.taxIdOrVat (personal) or Organization.taxIdOrVat (org). Auto top-up: personal uses User PMs; org prefers Organization.stripeDefaultPaymentMethodId on the org Customer, with legacy fallback to the owner’s User PM until the org has saved a card on the org Customer. API-level receipt copy (receipt_email, line descriptions, PaymentIntent metadata) is set in code — see Hosted Checkout (wallet) and Stripe customer emails. Card refunds from the super-admin panel call the Stripe Refunds API and align the wallet via webhooks (Stripe card refunds).
  • Dashboard billing portal (/dashboard/billing): Credits-only activity list + PDF invoice flow; workspace-scoped (personal vs org via cookie). Invoices gated for org MEMBERs (canDownloadWorkspaceInvoice in lib/org-permissions.ts). Billing entry point: header user menu (not sidebar).
  • Virtual numbers (US/CA): Search available numbers (Twilio), subscribe via Stripe Checkout (mode: subscription), webhooks provision Twilio + VirtualNumber / TwilioNumber; see Virtual number purchase.
  • SMS: Outbound send from user-owned TwilioNumber numbers only (UI copy: "From (purchased Ringvoo number)"); inbound via Twilio webhook; per-segment retail billing from imported SmsCountryPricing; wallet debit on outbound after Twilio accepts; inbound debit with optional negative-balance floor; status webhooks with monotonic status precedence; optional once-per-conversation inbound auto-reply SMS; protected Twilio REST recovery via /api/twilio/sync-sms.
  • Email notifications: styled verification/reset emails, simple support-form delivery, inbound SMS alerts to the owning user, and missed-call emails to the owning user with info@ringvoo.com reply handling.

Inbound voice billing: Inbound calls are wallet-gated in TwiML (validateVoiceCallStart + <Dial timeLimit=…>). Post-call settlement (call-statusupsertCallAndSettleWallet) applies retail charges using the same data/OutboundVoicePricing.csv machinery as outbound where configured: longest destination-prefix match on the called DID (To), with CSV origination filtered by the caller (From), then RINGVOO_VOICE_RETAIL_MULTIPLIER. Default RINGVOO_INBOUND_VOICE_PRICE_SOURCE (unset or not twilio) ignores Twilio’s cheap browser child-leg Price and bills from CSV so settlement matches the rate table; set to twilio to use Twilio webhook/REST carrier × multiplier instead. See Call status settlement flow.

Recent changes snapshot

This section summarizes the current working behavior after the last 6 commits on main (09b964d..HEAD) plus the recent local mail/recovery updates made after those commits.

  • Support + contact surface:
    • Added dashboard support UI (/dashboard/support) and marketing contact page (/contact).
    • Added POST /api/support plus lib/email.ts support mail delivery through Resend.
    • Support submissions route to SUPPORT_INBOX_EMAIL; auth emails use transactional sender/reply-to config.
  • Contacts:
    • Added contacts CRUD APIs and dashboard contacts experience.
    • Added public/contacts-template.csv and related contact import/export UX support.
  • Voice settlement refactor:
    • Replaced row-based CallHold reservation model with CallSession + CallSettlement.
    • Added wallet admission (balance > 0), low-balance <Dial timeLimit> stepping, provisional estimated settlement when Twilio price is late, and member quota accounting.
    • Added org member monthly voice quotas plus admin/team management surfaces.
  • Recent-call / wallet / max-duration polish:
    • Call history and wallet presentation were updated to match the new settlement/session model.
    • Max-duration preview, voice validation, and browser/client call metric handling were tightened to stay in parity with TwiML enforcement.
  • SMS + call user alerts (local updates after the last commit):
    • Added inbound SMS alert emails to the owning user.
    • Added missed-call emails to the owning user.
    • Alert emails send from Ringvoo Alerts <info@ringvoo.com> and reply to info@ringvoo.com.
    • Missed-call dedupe was tightened so parent/child Twilio callback families do not double-email on the same missed call.
  • SMS recovery (local updates after the last commit):
    • Added /api/twilio/sync-sms as a protected Twilio REST backfill endpoint.
    • Recovery imports only inbound messages sent to owned TwilioNumber rows and skips any MessageSid already present in SmsMessage.twilioMessageSid.
    • Backfilled SMS preserve Twilio's original DateSent / DateCreated as the stored message timestamp.
  • SMS auto-reply (local updates after the last commit):
    • Added an optional inbound auto-reply SMS feature.
    • Auto-reply sends once per conversation (first inbound message for (user, purchased number, remote sender)).
    • Reply text is configurable from env and uses the purchased number via {{number}}.
    • Auto-replies use the normal outbound SMS send/store/billing path.
  • SMS risk + collections controls (local updates):
    • Added a negative-balance warning banner in /dashboard/sms with a Buy credits CTA.
    • Added floor-skip counters in that banner (negative_balance_floor skips in last 24h / 7d).
    • Added super-admin /admin/sms-risk with per-user risk rows and manual Send top-up email action.
    • Added super-admin /api/admin/users/[id]/topup-reminder to manually send top-up reminder emails.
    • Updated inbound SMS debit policy to continue debiting while balance is negative, until SMS_NEGATIVE_BALANCE_LIMIT floor is reached.
  • Wallet billing portal (local):
    • /dashboard/billing lists TOPUP-only rows (?billing=1 on GET /api/user/wallet/transactions); invoice PDF via POST /api/billing/invoice (pdf-lib).
  • Voice pricing + dashboard performance:
    • CSV-first voice rates: data/OutboundVoicePricing.csv supplies country starting and max carrier rates where possible; Twilio Pricing API is fallback when CSV has no row (lib/billing/twilio-voice-pricing.ts, lib/billing/twilio-outbound-voice-csv.ts). Retail multiplier and billing debit behavior are unchanged.
    • Refreshing the outbound voice CSV: Twilio Pricing API v2 — Voice Countries (https://pricing.twilio.com/v2/Voice/Countries). App route: GET or POST /api/twilio/refresh-voice-pricing-csv (app/api/twilio/refresh-voice-pricing-csv/route.ts, core lib/twilio/refresh-outbound-voice-pricing-csv.ts). Local without dev server: npm run refresh-outbound-voice-pricing-csv (scripts/refresh-outbound-voice-pricing-csv.ts, loads .env via @next/env). Cron: vercel.json schedules /api/twilio/refresh-voice-pricing-csv weekly (0 6 * * 0 UTC); production uses CRON_SECRET as Authorization: Bearer … (Vercel injects when configured). next dev: bearer optional for this route and sync-cost-jobs (lib/cron-bearer-auth.ts). Some list ISOs return 404 on detail fetch (e.g. CU, IR, SY) — skipped and listed in the JSON response; Windows file locks may require closing OutboundVoicePricing.csv in the editor or using OutboundVoicePricing.csv.fresh (see route behavior).
    • Dashboard routing: /dashboard renders the phone page directly (no redirect hop to /dashboard/phone). SMS aliases (/dashboard/messages, usage) render the inbox module without an extra redirect; enterprise legacy buy-credits URL redirects to /dashboard/buy-credits so the canonical page does data loading once.
    • Active billing context: getPersonalWalletIdForUserId uses a short TTL in-memory cache plus in-flight dedupe to avoid repeated wallet.upsert on hot paths (lib/billing-context.ts). When the cookie still says org but resolveRequestContext falls back to personal, a short invalid-org cache skips repeat membership lookups; GET /api/context/active and GET /api/user/wallet may rewrite the httpOnly cookie to personal so the browser stops sending stale org context (persistPersonalContextCookieIfStaleOrgCookie in lib/context.ts).
    • Auth: getAuthSession uses React cache() (lib/auth-session.ts). The NextAuth jwt callback uses a ~1s in-memory cache for the minimal user snapshot (role, sessionVersion, email verified) to cut duplicate DB reads on concurrent session work (lib/auth.ts).
    • Dialer / marketing footer: DialerPreview calls the lightweight /api/public/dialer-footer-preview when country rate is on but max-time footer is off; heavy /api/twilio/voice/max-duration-preview only when max-time is enabled. Footer UX avoids country/rate flicker and keeps last max-time visible while refreshing.
    • Max-duration preview API: budget seconds + low-balance stepping mirror TwiML policy; short-lived in-memory response cache; optional [Perf] timing when RINGVOO_PERF_LOGS=true.
    • Buy credits: server-side parallel fetches where applicable; client refresh helpers dedupe and cool down rewards/auto-topup refetches.
    • Database: composite indexes on Call, VirtualNumber, and VirtualNumberOrder for common scoped queries (prisma/migrations/20260417163000_phase2_performance_indexes).
    • Twilio SDK: TwilioProvider preloads the Voice SDK only on phone/dialer routes.
    • Telemetry: opt-in [Perf]... logs gated by RINGVOO_PERF_LOGS (see Logging); safe to leave off in production.

Voice rate usage matrix (current policy)

Feature Main code path Rate type used Source used
Marketing dialer footer preview app/api/public/dialer-footer-preview/route.ts startingAt + max-country for safe duration CSV via lib/billing/twilio-outbound-voice-csv.ts; Twilio Pricing API fallback where that route still calls API
Public voice rate floor endpoint app/api/public/voice-rate-floor/route.ts Global minimum carrier floor CSV only (getMinCarrierRatePerMinuteUsdFromCsv)
Dashboard max-duration preview app/api/twilio/voice/max-duration-preview/route.ts Mostly max-country; uses prefix rate when destination is known CSV-first pricing helpers; API fallback where implemented
Dialer preflight admission app/api/voice/call-start/route.ts Max-country + prefix destination when E.164 known CSV-first (lib/billing/twilio-voice-pricing.ts)
TwiML outbound PSTN gate app/api/twilio/voice/outbound/route.ts Prefix retail $/min on callee To, origination = chosen PSTN callerId CSV getOutboundVoiceEstimateForDestinationgetRetailPerMinuteUsdForDestinationPrefixgetCarrierPerMinuteLongestDestMatchFromCsv
TwiML inbound gate app/api/twilio/voice/inbound/route.ts Prefix retail on called DID To, origination = PSTN caller From CSV getInboundVoiceEstimateForIso({ isoCountry, dialedToE164, callerFromE164 })getInboundRetailPerMinuteUsdWithPrefixFallback; falls back to country startingAt bucket if no row matches
Twilio <Dial timeLimit> decision app/api/twilio/voice/outbound/route.ts, app/api/twilio/voice/inbound/route.ts, lib/voice-dial-time-limit.ts Uses admission retail estimate from the corresponding estimator above Same CSV path as the TwiML row for that direction
Call session DB estimates (estimatedRatePerSec, estimatedCostPerMinuteUsd) createVoiceCallSession(...) after outbound / inbound TwiML admission Outbound PSTN: prefix-based estimate; Inbound: prefix-based on DID + caller (same as TwiML) CSV (lib/billing/voice-call-estimator.ts)
Free-trial destination eligibility lib/voice-call-validation.ts, lib/billing/free-trial-voice-call.ts, rate input from call routes Destination carrier $/min (prefix-aware when available), threshold check CSV helpers; see outbound TwiML for carrier derivation
Prefix destination matcher (shared) lib/billing/twilio-outbound-voice-csv.ts getCarrierPerMinuteLongestDestMatchFromCsv Longest destination-prefix within ISO; origination column filters rows data/OutboundVoicePricing.csv; if no prefix hit, falls back to country max in that helper
Post-call settlement (completed) lib/call-billing.ts upsertCallAndSettleWallet Outbound: Twilio webhook Price when present, else CSV prefix × billable minutes; Inbound (default): CSV prefix × minutes (Twilio child-leg price ignored); RINGVOO_INBOUND_VOICE_PRICE_SOURCE=twilio uses Twilio for inbound Webhook + CSV; see Call status settlement flow

High-level architecture

Frontend

  • components/dashboard/TwilioProvider.tsx: manages Twilio Device lifecycle (online/offline), outbound connect, inbound call UI & ringtone logic.
  • components/marketing/DialerPreview.tsx: includes UX for back-to-back settlement (toast/audio + wallet refresh), plus marketing-only audio cues.

Backend (Next.js App Router API routes)

  • Twilio TwiML endpoints:
    • app/api/twilio/voice/outbound/route.ts
    • app/api/twilio/voice/inbound/route.ts
  • Workspace + organizations:
    • app/api/context/active/route.ts (GET/POST active personal | org context cookie + membership list)
    • app/api/orgs/** (create/list org, members, transfer ownership, delete org)
    • lib/context.ts (resolveRequestContext), lib/context-query.ts (prismaScopedWhereForContext), lib/billing-context.ts (wallet from Twilio form / TwilioNumber row)
  • Twilio callbacks:
    • app/api/twilio/call-status/route.ts (billing settlement)
    • app/api/twilio/recording-status/route.ts (logs recording callbacks)
  • Admin/recovery:
    • app/api/twilio/sync-cost-jobs/route.ts (process due cost-sync jobs)
    • app/api/twilio/refresh-voice-pricing-csv/route.ts (rebuild data/OutboundVoicePricing.csv from Twilio Pricing API v2)
    • app/api/twilio/sync-calls/route.ts (manual reconciliation of recent Twilio calls)
    • app/api/twilio/sync-sms/route.ts (manual/cron-safe reconciliation of recent inbound Twilio SMS)
    • app/api/admin/sms-risk/route.ts (super-admin SMS risk rows: negative-balance users + floor-skip counters)
    • app/api/admin/users/[id]/topup-reminder/route.ts (super-admin manual top-up reminder email trigger)
    • app/api/cron/voice-wallet-safety/route.ts (optional hygiene: stale CallSession cleanup / wallet sanity — cron-auth)
    • app/api/twilio/call-holds/cleanup/route.ts (legacy endpoint; no CallHold rows in current schema — safe no-op / future removal candidate)
  • SMS:
    • app/api/twilio/sms/inbound/route.ts, app/api/twilio/sms-status/route.ts
    • app/api/sms/send/route.ts, app/api/sms/conversations/**, app/api/sms/logs/route.ts, app/api/sms/usage/route.ts
    • lib/sms-send.ts, lib/sms-inbound.ts, lib/billing/sms-pricing-lookup.ts, lib/billing/sms-config.ts
  • Email / support:
    • app/api/support/route.ts
    • lib/email.ts
  • Supporting data:
    • Outbound voice CSV refresh (bulk file): lib/twilio/refresh-outbound-voice-pricing-csv.ts
    • Twilio pricing fetch: lib/billing/twilio-voice-pricing.ts
    • Billing settlement logic: lib/call-billing.ts, lib/wallet.ts
    • Voice sessions + quota: lib/voice-call-sessions.ts, lib/org-member-voice-quota.ts, lib/voice-call-validation.ts, lib/voice-dial-time-limit.ts, lib/billing/voice-wallet-credit.ts, lib/billing/voice-call-estimator.ts
    • Settlement serialization: lib/pg-advisory-lock.ts (lockCallSettlement)
    • Legacy hold API surface (mostly no-ops): lib/call-holds.ts
    • Cost-sync worker: lib/call-cost-sync.ts
  • Stripe wallet funding:
    • app/api/billing/checkout-session/route.ts (create Checkout Session; persists tax on User or Organization by context)
    • app/api/webhooks/stripe/route.ts (verify signature, dispatch handlers)
    • lib/stripe/checkout-session.ts, lib/stripe/setup-checkout-session.ts, lib/stripe/checkout-adaptive-pricing.ts (shared STRIPE_CHECKOUT_ADAPTIVE_PRICING helper), lib/stripe/webhook-handlers.ts, lib/stripe/customer.ts (personal Stripe Customer), lib/stripe/org-customer.ts (organization Stripe Customer — getOrCreateStripeCustomerForOrganization), lib/stripe/client.ts, lib/stripe/constants.ts
    • lib/client-billing-context.ts (getRingvooContextHeaders() returns {} — billing APIs use the httpOnly ringvoo_active_context cookie only; avoids stale sessionStorage overriding the cookie)
    • lib/tax-id.ts (sanitize taxIdOrVat for DB + Checkout metadata)
    • lib/auto-topup.ts (off-session top-up trigger after call settlement debits)
    • lib/wallet.ts (creditWallet, call goodwill applySystemRefundIfEligible)
    • lib/stripe/call-credits-stripe-copy.ts (Checkout + PI Call Credits × … copy)
    • lib/stripe/refund-webhook.ts, lib/admin/stripe-refund-request.ts (Stripe card refund → ledger)
    • app/api/admin/payments/route.ts, app/api/admin/payments/refund/route.ts (super-admin Payment list + refund)
    • app/api/billing/setup-payment-method/route.ts (Checkout mode: setup — save card for auto top-up; org context uses org Stripe Customer)
    • app/api/billing/invoice/route.ts, lib/billing/invoice-pdf.ts (billing PDF invoices)
  • Virtual numbers:
    • app/dashboard/numbers/**, components/dashboard/numbers/NumbersPageClient.tsx
    • app/api/numbers/search, create-checkout, retry-provisioning, manage-subscription, [id] (PATCH)
    • lib/stripe/virtual-number-checkout.ts, lib/stripe/virtual-number-webhook.ts
    • lib/twilio/virtual-number-service.ts, lib/twilio/virtual-number-client.ts, lib/virtual-number/subscription-guards.ts

Database

  • Prisma schema: prisma/schema.prisma

Data model (Prisma)

Primary billing-related tables:

  • Wallet: USD balance (4 decimal places); personal wallets link to User, organization wallets link to Organization (organizationId). lastPurchaseAmount: most recent manual/auto top-up amount (USD); used for low balance mode detection (isVoiceLowBalanceMode in lib/billing/voice-wallet-credit.ts) alongside RINGVOO_LOW_BALANCE_THRESHOLD_PERCENT. Voice admission itself is balance > 0 (see Voice admission). Wallet credits are always tied to Wallet + Transaction; Stripe records which Customer was charged on each TOPUP row (stripeCustomerId column) — may be personal or org Customer id.
  • Organization (billing-related):
    • stripeCustomerId: Stripe Customer cus_… for company-named org billing (created via getOrCreateStripeCustomerForOrganization in lib/stripe/org-customer.ts on first org Checkout or org setup session).
    • stripeDefaultPaymentMethodId: default card on that org Customer (pm_…) for org auto top-up and after org wallet Checkout / org setup.
    • taxIdOrVat: optional VAT/tax ID string for org receipts (saved from org Buy credits / mirrored in Settings via GET /api/user/billing/auto-topup when scope is org).
    • ownerUserId: billing owner (Stripe customer email uses owner’s email when the org Customer is first created).
    • Auto top-up fields: autoTopupEnabled, autoTopupThresholdUsd, autoTopupAmountUsd, autoTopupInFlightPaymentIntentId (same pattern as User).
    • Migration: prisma/migrations/20260411210000_organization_stripe_pm_tax added stripeDefaultPaymentMethodId and taxIdOrVat on Organization (older DBs: run npx prisma migrate deploy).
    • Legacy tables removed: prisma/migrations/20260411230000_drop_team_team_member drops Team / TeamMember — multi-tenant membership is Organization + OrgMember only.
  • Transaction: immutable credit/debit ledger, with a unique constraint @@unique([referenceId, source]) for idempotency.
    • TOPUP rows: wallet credits from Stripe Checkout / auto top-up (source e.g. stripe_checkout_session, stripe_auto_topup), welcome bonus, and call goodwill refund (source = "refund" — positive credit; not the same as refunding a card charge in Stripe).
    • TOPUP rows may include Stripe audit fields: stripePaymentIntentId, stripeCheckoutSessionId, stripeCustomerId, fundingSource (manual_checkout | auto_topup).
  • User (billing-related): stripeCustomerId, stripeDefaultPaymentMethodId, autoTopupEnabled, autoTopupThresholdUsd, autoTopupAmountUsd, autoTopupInFlightPaymentIntentId, taxIdOrVat (optional VAT/tax ID string for receipts/metadata; not used for tax calculation; no card PAN/CVC stored).
  • StripeWebhookEvent: processed Stripe webhook event ids (evt_…) for idempotent handling.
  • StripeAutoTopupFailure: audit log when an off-session auto top-up PaymentIntent fails.
  • Call: persisted Twilio call leg (billable SID), with:
    • Billing scope: contextScope + contextId (PERSONAL + userId, or ORG + org id) — see Multi-tenant workspace.
    • direction: inbound | outbound (history/UI).
    • twilioCost: carrier-side USD stored for the leg (from Twilio and/or CSV-aligned synthetic carrier when settlement uses the rate table).
    • cost: retail charge debited from wallet (typically carrier × RINGVOO_VOICE_RETAIL_MULTIPLIER; see Call settlement).
    • Optional voice_pricing_source (twilio_webhook | csv_prefix), initial_voice_pricing_source (first non-null source, audit), needs_voice_reconcile (when true, cost sync may adjust; default inbound CSV settlement usually false).
  • CallSession: one row per outbound/inbound voice attempt while the leg may still be active. Tracks walletId, userId, optional organizationId, twilioParentCallSid, optional billableCallSid, status (ACTIVE | ENDED), destination estimatedRatePerSec / estimatedCostPerMinuteUsd (retail estimate from getOutboundVoiceEstimateForDestination on outbound PSTN or getInboundVoiceEstimateForIso on inbound — see Voice call sessions), startedAt / endedAt. Used for wallet resolution, duration fallbacks, and estimated settlement when Twilio price is late.
  • CallSettlement: idempotent one row per billable callSid recording final (or provisional) retail USD debited + optional Twilio carrier cost — prevents double settlement and supports estimated → final reconciliation.
  • OrgMemberVoiceQuota: per org+user monthly limit (monthlyLimitUsd, enabled).
  • OrgMemberVoiceQuotaUsage: rolling usage in periodKey = YYYY-MM (usedUsd), upserted on settlement debits for org members only.
  • CallCostSyncJob: delayed retries to fetch pricing and settle wallet if Twilio price was null at terminal time.
  • Removed (migration 20260414120000_voice_call_session_settlement): CallHold table and CallHoldStatus enum — replaced by the session + settlement model above.
  • UserCallerId: outbound caller ID entries per billing workspace (same pattern as virtual numbers):
    • Scope: contextScope + contextId (PERSONAL + userId, or ORG + org id); userId is the Ringvoo user who created the row.
    • Uniqueness: @@unique([contextScope, contextId, phoneNumber]) — the same E.164 can exist in personal and org workspaces separately.
    • verification state: verificationCode, verificationToken, verificationCallSid, verificationCallStatus, expiresAt, verificationAttempts
    • abuse/rate limiting: attempts, lastAttemptAt, nextAttemptAt
    • Migration: prisma/migrations/20260411180000_user_caller_id_context
  • TwilioNumber: maps a Twilio phone number to a Ringvoo userId and a billing scope (contextScope, contextId). Used for inbound voice routing, SMS, and virtual-number linkage; wallet for SMS debits follows contextScope / contextId via resolveWalletIdForTwilioNumberRow (lib/billing-context.ts).
  • VirtualNumber: one row per purchased virtual number subscription — links UserTwilioNumber, Stripe subscription id, billing period, status (active, past_due, etc.); includes contextScope / contextId for org vs personal purchases.
  • VirtualNumberOrder: checkout + provisioning audit; includes contextScope / contextId (Stripe metadata + provisioning).
  • SMS (see SMS (Twilio Messaging) and SMS (per workspace)):
    • SmsConversation: thread per (userId, twilioNumberId, remoteNumber) and scoped by contextScope / contextId (list APIs filter by active context).
    • SmsMessage: each message; same scope columns; stores segmentCount, ratePerSegment, billedAmount, Twilio provider cost fields, twilioMessageSid, status, wallet linkage.
    • SmsCountryPricing: per ISO country, direction (inbound | outbound), provider Twilio; stores providerPricePerSegment, retailPricePerSegment, markup/floor snapshot, active.

See: prisma/schema.prisma

Multi-tenant workspace (Personal vs Organization)

This section documents workspace switching, organization HTTP APIs, and how caller ID and SMS data are scoped. Stripe (Checkout, Customers, tax, auto top-up) is covered in Stripe wallet funding and cross-linked here only where needed.

Core migrations (among others): prisma/migrations/20260410120000_multi_tenant_context, 20260410140000_vn_order_context, 20260411180000_user_caller_id_context, 20260411190100_org_member_role_owner_backfill, 20260411230000_drop_team_team_member (legacy Team / TeamMember removed — use Organization + OrgMember).

Active context resolution

Module: lib/context.ts

  • RINGVOO_CONTEXT_COOKIE (ringvoo_active_context): httpOnly JSON { type: "personal" | "org", id: string }. Set by POST /api/context/active (30-day cookie, SameSite=lax, Secure in production).
  • resolveRequestContext(userId, { cookieRaw?, headerRaw? }):
    • Parses optional header x-ringvoo-context first: compact personal|<userId> or org|<orgId> (parseContextHeader). If absent, parses the cookie (parseCookieJson).
    • Personal: contextId === userId, walletId from getPersonalWalletIdForUserId (lib/billing-context.ts / lib/wallet.ts).
    • Org: loads OrgMember (ACTIVE) for (userId, orgId) including org wallet. If missing or org has no wallet, falls back to personal (never silently infers org).
    • orgRole: from membership, passed through syncOrgMemberRoleWithBillingOwner (lib/org-membership.ts) so the billing owner row stays consistent with Organization.ownerUserId.
  • Helpers: canShowPromoInContext — promos on Buy credits only in personal or when org role is privileged (lib/org-permissions.ts orgRoleIsPrivileged). canAccessOrgBilling (lib/context.ts) — false for plain MEMBER in org scope (UI/API gate for funding pages). canDownloadWorkspaceInvoice (lib/org-permissions.ts) — same idea for PDF invoice downloads on /dashboard/billing (org members see credit history but not invoice actions).

Dashboard performance (caching, perf logs)

This is not a second billing model — it only reduces duplicate work and fixes stale client state.

  • Personal wallet id (lib/billing-context.ts): getPersonalWalletIdForUserId wraps prisma.wallet.upsert with a ~15s in-process TTL cache and in-flight dedupe per userId. Same create-if-missing semantics as before; avoids hammering the DB when many server handlers resolve context in parallel.
  • Invalid org cookie (lib/context.ts): If the cookie says type: "org" but membership/org wallet resolution fails and the code falls back to personal, a ~10s in-memory cache keyed by userId:orgId skips repeating orgMember.findFirst on every request until TTL expires (then membership is checked again).
  • Cookie normalization (Route Handlers only): persistPersonalContextCookieIfStaleOrgCookie runs after resolveRequestContext on GET /api/context/active and GET /api/user/wallet. If resolution is personal but the httpOnly cookie still says org, the handler sets the cookie to {"type":"personal","id": userId} using RINGVOO_CONTEXT_COOKIE_SET_OPTIONS (same shape as POST /api/context/active). Next navigation sends a consistent cookie so the invalid-org path is not hit on every load.
  • NextAuth JWT (lib/auth.ts): Subsequent JWT refreshes read role / sessionVersion / emailVerified through a ~1s cache + in-flight dedupe per user id. Session invalidation via sessionVersion bump still works because stale cache entries expire quickly.
  • RINGVOO_PERF_LOGS: When true, selected pages and APIs log [Perf]... timings to the server console for debugging. Default off in normal runs. Dev next dev compile times still inflate wall-clock; use next build && next start or production to judge real user latency.

Context API (/api/context/active)

File: app/api/context/active/route.ts

Method Behavior
GET Returns { active: { scope, contextId, orgRole }, header, memberships }. header is the compact form `personal
POST Body { type: "personal", id: userId } or { type: "org", id: orgId }. Validates: personal id must equal session user; org requires ACTIVE membership. Sets cookie via shared RINGVOO_CONTEXT_COOKIE_SET_OPTIONS; returns { ok: true, header }.

Billing-heavy client fetches should not send a stale x-ringvoo-context from sessionStorage over the cookie — see Active billing context (cookie and API headers).

Context switch lifecycle in dashboard UI

Files: components/dashboard/UserMenu.tsx, components/dashboard/WalletBalanceContext.tsx, lib/client-billing-context.ts

  • Switcher entrypoint: UserMenu calls POST /api/context/active with { type, id }, writes the returned compact header (personal|... or org|...) to sessionStorage.ringvoo_ctx_header, updates local activeCtx optimistically, then dispatches window.dispatchEvent(new Event("ringvoo-context-changed")).
  • Why both cookie + session storage exist: the cookie is authoritative for server resolution (resolveRequestContext), while sessionStorage.ringvoo_ctx_header is a client mirror for synchronous browser-side consumers (e.g. Twilio connect params), not for billing API authority.
  • Wallet/provider refresh path: WalletBalanceProvider listens for ringvoo-context-changed, shows a switching overlay, refreshes /api/user/wallet, refreshes /api/context/active (to mirror the latest compact header), and calls router.refresh() so server components re-render in the new workspace.
  • Header policy for billing fetches: getRingvooContextHeaders() intentionally returns {}; billing and wallet APIs rely on the httpOnly context cookie to avoid stale sessionStorage overriding context after redirects/back navigation.
  • Workspace visibility: the workspace chooser is rendered only when the user has at least one active org membership; with zero memberships, top-bar copy stays as user identity only.

Organization management APIs

Base: app/api/orgs/

Route Method Who Purpose
/api/orgs GET Member List current user’s org memberships (orgId, role, name, ownerUserId, createdAt).
/api/orgs POST Any signed-in user Create org: nameOrganization + OWNER OrgMember + org Wallet.
/api/orgs/[orgId] DELETE OrgMember.role === OWNER for that org Deletes org after deleteOrganizationScopedData (see Organization deletion).
/api/orgs/[orgId]/members GET Privileged (OWNER/ADMIN) Member list + per-member call stats (lib/org-member-call-stats.ts); runs healBillingOwnerMembershipRow.
/api/orgs/[orgId]/members POST Privileged + invite rules Invite by email (user must already exist). Body: email, optional role ADMIN | MEMBER. Only OWNER may invite ADMIN (lib/org-team-policy.ts canInviteWithRole).
/api/orgs/[orgId]/members/[memberUserId] PATCH OWNER/ADMIN (not MEMBER) Set member role ADMIN or MEMBER only — not OWNER (use transfer).
/api/orgs/[orgId]/members/[memberUserId] DELETE OWNER/ADMIN per policy Remove member; owner cannot remove self without transfer (canRemoveMember).
/api/orgs/[orgId]/members/[memberUserId]/voice-quota GET, PUT Privileged (OWNER/ADMIN) Read/update OrgMemberVoiceQuota (monthlyLimitUsd, enabled) for that member; drives MEMBER outbound voice gating.
/api/orgs/[orgId]/transfer POST Billing owner (Organization.ownerUserId) Body newOwnerUserId (must be ACTIVE member). Updates ownerUserId; previous owner becomes ADMIN, new owner OWNER (OrgMember.role).

Roles and team policy

Files: lib/org-permissions.ts, lib/org-team-policy.ts

  • MEMBER: Usage: calls, SMS, numbers assigned in org context — no org billing admin, no inviting, no funding Stripe flows meant for privileged users.
  • ADMIN / OWNER (orgRoleIsPrivileged): Wallet funding, Buy credits (where allowed), billing settings, team management subject to canInviteWithRole, canChangeMemberRole, canRemoveMember.
  • OWNER on OrgMember tracks membership; Organization.ownerUserId is the billing owner (Stripe org Customer email seed, transfer target, delete org).

Row scoping helper (context-query)

File: lib/context-query.ts

  • prismaScopedWhereForContext(userId, ctx) builds a Prisma where fragment for models keyed by workspace:
    • Personal: { userId, contextScope: PERSONAL, contextId: userId }.
    • Org: { contextScope: ORG, contextId: orgId }no userId filter (shared org data; membership is enforced earlier in resolveRequestContext).
  • isOrgMemberInResolvedContext: scope === ORG && orgRole === MEMBER (for UX that differs for “full” members vs admins).

Used by GET /api/user/caller-ids and SMS list routes, among others.

Custom caller IDs (per workspace)

Files: app/api/user/caller-ids/route.ts (and [id], confirm), lib/context-query.ts

  • Every request calls resolveRequestContext with headerRaw: req.headers.get("x-ringvoo-context") so list/create/update targets the active workspace.
  • Rows are filtered with prismaScopedWhereForContext; verification updates include contextScope / contextId so the same human can maintain different verified numbers in personal vs org.

See also Custom caller ID verification for the Twilio validation flow.

SMS (per workspace)

List APIs: e.g. GET /api/sms/conversationsresolveRequestContext + prismaScopedWhereForContext so inbox/logs match the active org or personal workspace.

Wallet debit (outbound): lib/sms-send.ts resolves the debited wallet with resolveWalletIdForTwilioNumberRow from the TwilioNumber row: ORG → org Wallet by organizationId === contextId; PERSONAL → user’s personal wallet (ensurePersonalWalletForUser).

Sender identity rule: outbound SMS can only use owned TwilioNumber rows as the From number. Verified custom caller IDs (UserCallerId) are for voice caller identity only and are not valid SMS sender options in the current product/Twilio model.

Voice (related): Twilio Voice form params BillingContextType / BillingContextId map to the same wallet resolution via resolveWalletIdFromTwilioForm in lib/billing-context.ts (org + org id → member’s org wallet).

Organization deletion (DB cleanup)

Files: app/api/orgs/[orgId]/route.ts (DELETE), lib/org-delete-cleanup.ts

  • deleteOrganizationScopedData deletes rows with { contextScope: ORG, contextId: orgId } for: SmsMessage, SmsConversation, VirtualNumberOrder, VirtualNumber, TwilioNumber, Call, UserCallerId, and org-scoped Promo rows.
  • Does not cancel Stripe subscriptions or release Twilio numbers in provider APIs — do that before deleting a production org if you need a clean cloud state.

Twilio Voice flows

Outbound call flow

Entry point: POST /api/twilio/voice/outbound (app/api/twilio/voice/outbound/route.ts)

What happens (outbound PSTN only):

  1. Twilio hits the TwiML URL when the browser places a call via Twilio.Device.
  2. We parse:
    • To (destination)
    • From (Twilio client identity, e.g. client:<userId> or raw id/email)
    • CallSid (this is the parent call SID for the TwiML request)
    • BillingContextType / BillingContextId (personal vs org wallet)
  3. We resolve the user from identity and org membership (if org billing).
  4. Admission + rate estimate (no CallHold rows — see Voice admission):
    • Load destination ISO country (getIsoCountryFromPhoneNumber on callee To).
    • getOutboundVoiceEstimateForDestination() (lib/billing/voice-call-estimator.ts): retail $/min from longest CSV destination-prefix match on To, with CSV origination filtered by the PSTN callerId chosen for <Dial> (public / owned number / verified custom) — getRetailPerMinuteUsdForDestinationPrefix in lib/billing/twilio-voice-pricing.ts.
    • validateVoiceCallStart()evaluateVoiceCallLiquidity() (lib/voice-call-validation.ts): block when wallet.balance ≤ 0; otherwise returns lowBalanceMode from isVoiceLowBalanceMode() (lib/billing/voice-wallet-credit.ts). creditLimitUsd is always 0 (no negative-balance extension).
  5. Org member quota (role MEMBER only): assertOrgMemberVoiceQuotaAllowsCall() with projectedChargeUsd = estimatedCostPerMinuteUsd — must have enabled quota with positive limit and enough monthly headroom (lib/org-member-voice-quota.ts). Missing/disabled/zero limit → OrgMemberQuotaNotConfiguredError (no outbound calls until an admin configures quota).
  6. We create a CallSession (createVoiceCallSession in lib/voice-call-sessions.ts) with the estimate and parent SID; createOrRefreshCallHold() is still invoked but is a no-op (legacy hook).
  7. Dial time limit: computeVoiceDialTimeLimitSeconds() (lib/voice-dial-time-limit.ts):
    • Normal balance: cap = RINGVOO_VOICE_MAX_CALL_SECONDS (default 86400s, clamped), i.e. effectively unlimited for typical calls.
    • Low balance mode: compute budget-based seconds with computeMaxDurationDecisionFromRate() using minAllowedSeconds = 1, safetyBufferSeconds = 0, then pick the largest configured step ≤ that budget (RINGVOO_LOW_BALANCE_TIME_LIMIT_STEPS_SECONDS, default 60,120,180). If budget is below the smallest step, TwiML still uses the smallest step (product policy).
  8. We return TwiML that dials the destination with:
    • timeLimit from step 7
    • statusCallback and recordingStatusCallback (when SERVER_URL is set)

Insufficient funds / quota behavior

  • VoiceCallInsufficientBalanceError → TwiML message + hangup.
  • Org quota errors → TwiML message + hangup (member cannot place call until quota is fixed or increased).

Inbound call flow

Entry point: POST /api/twilio/voice/inbound (app/api/twilio/voice/inbound/route.ts)

What happens:

  1. Validate Twilio webhook signature (validateTwilioWebhook() in lib/twilio.ts).
  2. Parse From, To (the Twilio number that was called), and CallSid.
  3. Map To → owning Ringvoo user using TwilioNumber table via getUserFromTwilioNumber() in lib/twilio.ts.
  4. Return TwiML:
    • spoken “Please hold while we connect your call.” (<Say>)
    • <Dial><Client identity="<userId>" /></Dial>
    • adds statusCallback and recordingStatusCallback if SERVER_URL exists

Inbound wallet gate: before <Dial><Client>, we run the same validateVoiceCallStart + createVoiceCallSession path as outbound, using getInboundVoiceEstimateForIso({ isoCountry, dialedToE164: To, callerFromE164: From }) (lib/billing/voice-call-estimator.ts): retail $/min from the same longest-prefix CSV logic as outbound — destination = called DID (To), origination = caller (From) — via getInboundRetailPerMinuteUsdWithPrefixFallback in lib/billing/twilio-voice-pricing.ts, with fallback to the country startingAt bucket if no row matches. If admission fails (wallet ≤ 0), the PSTN caller hears the inbound low-balance prompt and the call ends (no browser ring). The callee may also receive a missed-call email (sendMissedCallAlertEmail with status = blocked-insufficient-balance).

Inbound low-balance audio: Twilio prefers hosted audio when possible:

  • optional absolute URL: RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_URL (must be http:// or https://)
  • otherwise SERVER_URL + RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_PATH (defaults to /sounds/ringvoo-inbound-caller-unavailable.wav)
  • if no safe https:// audio URL can be built, Twilio falls back to TTS using RINGVOO_INBOUND_LOW_BALANCE_CALLER_MESSAGE

Max duration on <Dial> is still set from computeVoiceDialTimeLimitSeconds (low-balance stepping when applicable).

Call status settlement flow

Entry point: POST /api/twilio/call-status (app/api/twilio/call-status/route.ts)

Inbound Call.toNumber (history/UI): On the billable child leg Twilio often sends To=client:<userId>. Before settlement, the handler resolves a human-readable DID with resolveInboundToNumber (lib/call-billing.ts): prefer a parent Call row’s toNumber, else fetchTwilioCallDetails(parentCallSid).to so Call.toNumber stores the E.164 that was called when possible.

Purpose:

  • Normalize Twilio webhook payload into the billable call leg SID.
  • Fetch Twilio REST call details at terminal time.
  • Upsert the Call row.
  • Debit wallet (retail) when pricing is resolved; or provisional estimated debit + CallSettlement when completed but price missing (see Voice call sessions).
  • Apply refund rules and org member quota usage deltas on debits/refunds.
  • Advisory lock per billable SID (lockCallSettlement in lib/pg-advisory-lock.ts) inside the transaction to reduce duplicate webhook write conflicts.
  • End matching CallSession rows (endVoiceCallSessionsForTerminal).
  • Legacy settlePendingHoldForCall / CallHold hooks are no-ops.
  • Enqueue delayed retries if the call completed but Twilio price is missing.

Key behaviors:

  • Webhook signature validation: rejects invalid signatures (403).
  • Billable SID selection:
    • If Twilio provides DialCallSid, bill that child leg.
    • If only a parent leg exists, try to find the child leg via REST (findChildCallByParentSid()).
    • If still no child leg but the parent leg is terminal (busy/failed/no-answer/canceled), we may persist the parent leg as the billable sid (zero charge).
  • Terminal-only processing:
    • Only processes: completed, busy, failed, no-answer, canceled.
    • Non-terminal statuses are acknowledged but skipped.
  • Second fetch for “completed but price missing”:
    • If terminal is completed and REST price is empty, waits ~1500ms and fetches again.
  • Owning user resolution:
    • Tries to map by Twilio numbers (getUserFromTwilioNumber(from|to)).
    • If not found, tries Twilio client identity parsing (client:<userId>).

Settlement entry point:

  • upsertCallAndSettleWallet() in lib/call-billing.ts
  • After a successful wallet debit for the call (walletDebited: true), maybeTriggerAutoTopupAfterDebit(userId, walletId) may run to charge a saved card if auto top-up is enabled and available balance is below the configured threshold (personal vs org wallet — see Auto top-up; Stripe wallet funding).

Voice call sessions, provisional settlement, and org member quota

Migration: prisma/migrations/20260414120000_voice_call_session_settlement — adds CallSession, CallSettlement, Wallet.lastPurchaseAmount; drops CallHold.

CallSession (lifecycle)

  • Created when a call is accepted in TwiML (outbound / inbound): stores wallet, user, optional organizationId, parent Twilio SID, destination ISO, and estimated retail rate (estimatedRatePerSec = estimatedCostPerMinuteUsd / 60 from getOutboundVoiceEstimateForDestination on outbound PSTN or getInboundVoiceEstimateForIso on inbound — see Voice rate usage matrix).
  • billableCallSid is filled later via bindVoiceCallSessionBillableSid() when the child leg SID is known (matches parent or orphan billableCallSid patterns).
  • Ended on terminal call-status: status = ENDED, endedAt set — endVoiceCallSessionsForTerminal() matches by billable SID, parent SID, or parent-as-terminal edge cases.
  • Race: if a new session is created for a parent SID that already has a terminal Call, the session may be created already ENDED (logged warning) to avoid stuck ACTIVE rows.

CallSettlement (idempotency + provisional → final)

  • Unique on callSid (billable leg). Records amountUsd (retail charged to wallet) and optional twilioCarrierCostUsd.
  • When Twilio price exists on completed: normal path debits Transaction with referenceId = billableCallSid, creates CallSettlement with carrier + retail, applies applySystemRefundIfEligible short-call rules.
  • When Twilio price is missing on completed: upsertCallAndSettleWallet debits an estimated amount = estimatedRatePerSec × duration from the matching CallSession, referenceId = <sid>:estimated, CallSettlement row with twilioCarrierCostUsd = null, Call.billingStatus = settled_estimated. When a later callback/job provides price, existingSettlement.twilioCarrierCostUsd === null triggers delta debit or refund (:final-delta / :final-delta-refund) and updates CallSettlement + Call.cost to settled.
  • Org member quota: applyMemberQuotaDeltaIfApplicable() runs on each real debit/refund delta for org MEMBER role only — updates OrgMemberVoiceQuotaUsage for the current UTC month (periodKey).

Org member voice quota (admin-configured)

  • Tables: OrgMemberVoiceQuota (limit + enabled), OrgMemberVoiceQuotaUsage (per YYYY-MM).
  • Pre-call gates: assertOrgMemberVoiceQuotaAllowsCall() — same projected charge as admission uses (estimatedCostPerMinuteUsd per minute) for: TwiML outbound (/api/twilio/voice/outbound), dialer preflight POST /api/voice/call-start, and GET /api/twilio/voice/max-duration-preview (preview zeros availableBalanceUsd / marks insufficient when quota blocks).
  • Errors: OrgMemberQuotaNotConfiguredError (no row, disabled, or monthlyLimitUsd ≤ 0) — members cannot place calls until fixed; OrgMemberQuotaExceededError when projected usage would exceed limit.
  • Admin API: PUT /api/orgs/[orgId]/members/[memberUserId]/voice-quota (privileged roles) — see route handler for validation.

Preflight parity

  • POST /api/voice/call-start mirrors TwiML liquidity + member quota checks so the dialer can block before Twilio connects.

Legacy call-holds stubs (no row reservations)

The module lib/call-holds.ts remains imported for API compatibility but does not persist rows:

  • getAvailableBalanceUsd() returns availableUsd = wallet.balance (active holds = 0); expirePendingCallHolds / createOrRefreshCallHold / settlePendingHoldForCall / compressPendingHoldForCall are no-ops (or return null/false).
  • reconcileTerminalCallHolds() is effectively a no-op but kept in transactions for minimal churn.
  • SMS outbound pre-send still uses getAvailableBalanceUsd() — with holds always zero, SMS sees gross wallet balance (same as voice “available” in this model).

Delayed pricing: cost sync jobs

Why this exists

Twilio’s price field is often null on the first terminal callback for completed calls. Without retries we’d underbill.

How it works

  • If a completed call has twilioCostUsd === null, call-status enqueues a job:
    • enqueueCallCostSyncJob() in lib/call-cost-sync.ts
  • Jobs retry with delays: [15, 45, 120, 300] seconds.
  • Jobs are idempotent and safe to re-run:
    • billing itself is idempotent via Transaction referenceId
  • kickDueCostSyncJobsInBackground() runs a few quick local retries (dev convenience).
  • GET /api/twilio/sync-cost-jobs processes due jobs (cron-friendly). Jobs are skipped (no-op complete) for inbound calls already settled from CSV when RINGVOO_INBOUND_VOICE_PRICE_SOURCE is not twilio, so reconcile does not overwrite table-based inbound charges (lib/call-cost-sync.ts).
  • GET/POST /api/twilio/refresh-voice-pricing-csv rebuilds data/OutboundVoicePricing.csv from Twilio Pricing API v2 (scheduled in vercel.json; local: npm run refresh-outbound-voice-pricing-csv).

Custom caller ID verification

Ringvoo supports a Twilio voice-based verification flow for custom outbound caller IDs.

Current billing policy (important):

  • Custom caller-ID verification calls are free for end users right now.
  • Twilio carrier cost is currently absorbed by Ringvoo (platform-paid).
  • No wallet debit/hold/transaction is created for this verification flow at this time.
  • Optional terminal logging for verification call price/duration is available via TWILIO_LOG_CUSTOM_CALLER_ID_COST=true.

Workspace scope: Caller IDs are stored per billing context (UserCallerId.contextScope / contextId); the same E.164 can appear once per personal workspace and once per org. APIs use resolveRequestContext + prismaScopedWhereForContext — see Custom caller IDs (per workspace).

Primary routes

  • User API:
    • GET /api/user/caller-ids — list caller IDs for the active workspace (cookie / x-ringvoo-context).
    • POST /api/user/caller-ids — start verification call for an E.164 number in that workspace.
  • Twilio verification callbacks:
    • POST /api/twilio/outgoing-caller-id/validation-status — Twilio Outgoing Caller ID validation result (VerificationStatus = success | failed). On success, the number is registered as a Verified Caller ID on the Twilio account and Ringvoo marks UserCallerId.verified.
    • Legacy routes (/api/twilio/custom-caller-id/voice, confirm, status) are no longer used by POST /api/user/caller-ids; verification uses Twilio’s REST Validation Request API (validationRequests.create).
  • Admin:
    • GET /api/admin/caller-ids — super-admin filtered caller ID listing.
    • /admin/caller-ids — super-admin page for debugging/inspection.

Flow

  1. User submits a caller ID number (POST /api/user/caller-ids).
  2. Server normalizes and validates E.164 (+ + 8-15 digits), checks uniqueness, and enforces cooldown (nextAttemptAt, currently 30s).
  3. If the number is already a Twilio Incoming Phone Number or Verified Caller ID on this account, Ringvoo marks the row verified immediately (no call).
  4. Otherwise the server calls Twilio validationRequests.create (Outgoing Caller ID API). Twilio returns a 6-digit validationCode synchronously and places its own verification call to the user’s number. The response includes the verification Call SID.
  5. Ringvoo stores the code in UserCallerId.verificationCode (for display in the modal), verificationCallSid, expiresAt (~10 minutes), and SERVER_URL-based statusCallback pointing at POST /api/twilio/outgoing-caller-id/validation-status.
  6. The user answers Twilio’s call and enters the code when prompted. Twilio then adds the number to Verified Caller IDs for the account.
  7. Twilio invokes the status callback with VerificationStatus (success | failed). On success, Ringvoo sets verified = true and clears verification fields.
  8. GET /api/user/caller-ids also syncs pending rows: if Twilio already lists the number, Ringvoo marks it verified (covers missed callbacks).

Operational rules

  • Uniqueness is per workspace: @@unique([contextScope, contextId, phoneNumber]) — not global per user; two workspaces may each verify the same E.164.
  • Cooldown/rate-limit errors return HTTP 429 with Retry-After.
  • Verification call creation failures return 502 to the caller.
  • All Twilio webhook endpoints validate signatures and fail closed (403) on invalid signatures.

Twilio account authorization (outbound PSTN caller ID)

Successful completion of the Validation Request flow registers the number as a Twilio Verified Caller ID on the same Account SID as TWILIO_ACCOUNT_SID, so <Dial callerId> is honored. Numbers that are Incoming Phone Numbers on that account are already valid and are detected without a new validation call.

  • Manual Console entry is no longer required for the primary flow; optional fallback remains if you pre-verify outside Ringvoo.
  • Outbound TwiML logs may include twilioAccountAuthorizesCallerId when voice logging is enabled. Set TWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED=true only if you want to force fallback to TWILIO_PHONE_NUMBER when Twilio does not authorize the selected ID.

CallFromMode (dialer request → outbound TwiML)

The dialer sends CallFromMode to POST /api/twilio/voice/outbound:

  • public → use TWILIO_PHONE_NUMBER
  • phone → use selected owned number (CallFromNumber)
  • custom → use selected verified custom caller ID (CallFromNumber)

Key outbound logs:

  • callFromMode
  • pstnCallerId
  • twilioAccountAuthorizesCallerId (for phone/custom)

Troubleshooting rule:

  • If callee sees wrong caller ID, first inspect [Twilio Voice] Outbound call TwiML generated.
  • If callFromMode: 'public', the app sent public mode (this is not Twilio fallback logic).
  • If callFromMode: 'custom' and twilioAccountAuthorizesCallerId: false, behavior depends on fallback env and Twilio/carrier handling.

Recent fix:

  • A UI state desync could show a selected number label while sending CallFromMode='public'.
  • Dialer now performs a pre-connect consistency check:
    • label matches owned number → force phone
    • label matches verified custom number → force custom
    • otherwise keep public

Observed scenario: removed in Twilio Console but still displayed

During testing, removing a custom caller ID from Twilio Console could still result in that number being presented on some calls while logs reported twilioAccountAuthorizesCallerId=false.

Interpretation:

  • this is observed runtime/carrier behavior, not a guarantee
  • Ringvoo still sends selected callerId if fallback env is off
  • Twilio/carrier may present, substitute, or reject depending on route/timing

International destinations (e.g. UAE)

Even with a valid Twilio callerId, CLI preservation is not guaranteed on every route; carriers may alter or reject presentation. See Twilio’s “International Voice Quirks” documentation for limitations.

SMS (Twilio Messaging)

Ringvoo bills SMS in USD from the user wallet, using per-segment retail rates stored in SmsCountryPricing. Voice does not use row-level holds; getAvailableBalanceUsd() returns gross wallet balance (same as voice “available” for SMS pre-send checks).

Key code paths

Area Files
Segment counting lib/sms-segments.ts (sms-length)
E.164 → ISO country lib/sms-country.ts (getCountryIsoFromE164, fallback XX)
Active pricing row lib/billing/sms-pricing-lookup.ts (findActiveSmsPricing)
Markup / floor / retail formula lib/billing/sms-config.ts
CSV import lib/billing/sms-pricing-import.ts, scripts/import-sms-pricing.ts
Outbound send + debit lib/sms-send.ts, app/api/sms/send/route.ts
Inbound webhook + debit lib/sms-inbound.ts, app/api/twilio/sms/inbound/route.ts
Outbound status webhook app/api/twilio/sms-status/route.ts, lib/sms-status-precedence.ts
US/CA-only outbound rule lib/sms-destination.ts

SMS data model

  • SmsConversation: One thread per (userId, twilioNumberId, remoteNumber) plus billing scope contextScope / contextId (same user can have parallel threads in personal vs org when switching workspace). Updated on each message (preview, lastMessageAt, unread for inbound). List APIs filter by active context.
  • SmsMessage: Stores body, direction (inbound | outbound), segmentCount, encodingType (gsm7 | ucs2), Twilio twilioMessageSid, status, ratePerSegment, billedAmount, provider cost fields, walletDebited, optional billingSkippedReason when pricing or debit was skipped; includes contextScope / contextId for org-aware reporting.
  • SmsCountryPricing: One row per (countryIso, direction, provider) with providerPricePerSegment, snapshot markupMultiplier, minimumPriceFloor, computed retailPricePerSegment, currency, active.

Ownership of the From (outbound) / To (inbound) Twilio number is enforced via TwilioNumber.userId (the Ringvoo user who owns the number). Which wallet is debited follows TwilioNumber.contextScope / contextId (personal vs org) — see SMS (per workspace).

Segment counting

  • Outbound (pre-send estimate): computeSmsSegments(body) uses the sms-length library → segmentCount (minimum 1) and GSM-7 vs UCS-2 (UTF16ucs2).
  • Inbound: Prefer Twilio’s NumSegments from the webhook when it parses as a finite integer ≥ 1; otherwise fall back to computeSmsSegments(body) (same as outbound).

Total retail charge for both directions:

billedAmount = retailPricePerSegment × segmentCount

The minimum floor in env applies per segment inside the retail formula (see below), not once per message.

Country pricing rows (SmsCountryPricing)

findActiveSmsPricing({ countryIso, direction }) loads provider = "twilio", active: true:

  • Normalizes ISO to uppercase; invalid/empty → XX (SMS_UNKNOWN_COUNTRY_ISO).
  • Outbound: countryIso is derived from the destination To number (getCountryIsoFromE164(normalizedTo)).
  • Inbound: countryIso is derived from the sender From number (where the external party is).

If no row exists for the resolved ISO, pricing falls back to the XX row when present (satellite / unknown bucket in the CSV).

Retail price per segment (formula)

At import time and stored on each SmsCountryPricing row:

retailPricePerSegment = round_up_4dp( max( providerPricePerSegment × M, F ) )

Where:

  • M = direction-specific markup: outbound SMS_OUTBOUND_MARKUP_MULTIPLIER (default 2.0), inbound SMS_INBOUND_MARKUP_MULTIPLIER (default 2.0).
  • F = direction-specific floor: outbound SMS_OUTBOUND_MINIMUM_FLOOR_USD (default 0.0100), inbound SMS_INBOUND_MINIMUM_FLOOR_USD (default 0.0050).
  • round_up_4dp = round up to 4 decimal places (Prisma.Decimal.ROUND_UP).

At send/receive time, billing uses the precomputed retailPricePerSegment on the row (not recomputed from env on every message), so changing env requires re-running the pricing import to refresh stored retail and snapshots.

Provider-side estimate stored on the message for analytics:

providerCostTotal = providerPricePerSegment × segmentCount

Pricing import (SMSPricing.csv)

  • Command: npm run import-sms-pricing (runs tsx scripts/import-sms-pricing.tsrunSmsPricingImport()).
  • Source file: data/SMSPricing.csv — columns ISO, Country, Description, Price / msg; only rows whose description contains outbound (case-insensitive) are used.
  • Aggregation: For each ISO, Twilio’s maximum outbound Price / msg across those rows becomes providerPricePerSegment for that country’s outbound row (conservative vs multiple carriers in the file).
  • Inbound provider seed: The CSV does not include inbound rates. Inbound providerPricePerSegment is seeded from the same max outbound provider rate per country (documented in sourceLabel on the row). Retail inbound still uses inbound markup and floor from env. Replace with a dedicated inbound column/source when Twilio inbound pricing is modeled separately.

Outbound SMS: cost calculation and flow

  1. Destination guard: Outbound sends are allowed only to US and Canada (+1 E.164); see isUsCanadaSmsDestination() in lib/sms-destination.ts.
  2. Ownership: fromNumber must be a TwilioNumber owned by the user.
  3. Segments + pricing: segmentCount from computeSmsSegments(body); findActiveSmsPricing({ countryIso: dest, direction: "outbound" }).
  4. Retail total: billed = retailPricePerSegment × segmentCount (from the active outbound row).
  5. Balance check: getAvailableBalanceUsd() must be ≥ billed (equals gross wallet balance; voice does not subtract row-level holds).
  6. Twilio: sendSmsViaTwilio — on success, a SmsMessage row is created and debitWallet runs in the same DB transaction with source: "sms_outbound", type: "SMS", referenceId: twilioMessageSid. walletDebited is set true when the debit succeeds.
  7. Auto top-up: maybeTriggerAutoTopupAfterDebit(userId, walletId) runs after a successful outbound flow (same pattern as calls).

Outbound cost summary

Step What you charge the user
Per-segment retail From SmsCountryPricing (outbound row for destination ISO, or XX fallback)
Message total retailPricePerSegment × segmentCount
When debited Immediately after Twilio accepts the send (message SID known), in the same transaction as the SmsMessage insert

Inbound SMS: cost calculation and flow

  1. Webhook: POST /api/twilio/sms/inbound validates the Twilio signature, then processInboundSms() in lib/sms-inbound.ts.
  2. Dedup: Duplicate MessageSid / SmsSid returns success without double-inserting.
  3. Ownership: To must match a TwilioNumber.phoneNumber (the user’s inbound number).
  4. Segments: NumSegments from Twilio if valid, else computeSmsSegments(body).
  5. Pricing: findActiveSmsPricing({ countryIso: senderCountry, direction: "inbound" }) where senderCountry = getCountryIsoFromE164(From).
  6. Retail total: If pricing exists, billed = retailPricePerSegment × segmentCount. If no row, the message is still stored with billingSkippedReason / null billing fields as appropriate.
  7. User alert email: after a successful insert, sendInboundSmsAlertEmail() notifies the owning user email; alert mail uses ALERT_FROM_EMAIL and replies route to ALERT_INBOX_EMAIL.
  8. Recovered/backfilled SMS: when imported via /api/twilio/sync-sms, the same processor runs with Twilio REST fields mapped into webhook-shaped params, preserving original DateSent / DateCreated so the inbox/history timestamp matches provider event time rather than sync time.
  9. Optional auto-reply SMS: if SMS_INBOUND_AUTO_REPLY_ENABLED is on and no prior SmsConversation exists for (userId, twilioNumberId, remoteNumber), Ringvoo sends one outbound reply from the purchased number back to the inbound sender using sendAutoReplySms().

Downtime behavior: if the server/public webhook URL is down when Twilio first tries to deliver an inbound SMS webhook, the live insert may be missed. /api/twilio/sync-sms is the recovery path for that case: it later pulls recent inbound SMS from Twilio REST and imports any missing MessageSids.

Auto-reply behavior: this is intentionally once per conversation, not on every inbound message and not currently time-window-based. If the same remote number keeps texting in the same conversation, Ringvoo continues to store the inbound SMS and email-alert the user, but it does not send another auto-reply.

Inbound cost summary

Step What you charge the user
Per-segment retail From SmsCountryPricing (inbound row for sender ISO, or XX fallback)
Message total retailPricePerSegment × segmentCount
When debited In the same transaction as insert, via debitWalletInboundSms (if pricing present and amounts valid)

Inbound vs outbound (which country row?)

  • Outbound billable country = where the SMS is going (destination).
  • Inbound billable country = where the SMS came from (sender’s country), because Twilio’s inbound rate conceptually depends on the originating network/country bucket modeled in our table.

Wallet debits (SMS)

  • Outbound: Standard debitWallet() — will not debit below zero in normal mode; insufficient balance is blocked before Twilio send, and the transaction path expects success.
  • Inbound: debitWalletInboundSms() in lib/wallet.ts:
    • If SMS_ALLOW_NEGATIVE_BALANCE is false/0: behaves like debitWallet with onInsufficient: "skip" — no debit if balance insufficient; message still stored; walletDebited false with a skip reason.
    • If enabled (default): allows debiting until wallet balance would drop below SMS_NEGATIVE_BALANCE_LIMIT (default -5 USD), including when balance is already negative. If the debit would cross that floor, debit is skipped (below_floor / negative_balance_floor).
  • Idempotency: referenceId = Twilio message SID, source sms_inbound / sms_outbound with the same unique constraint as other wallet transactions.
  • Successful inbound debit also triggers maybeTriggerAutoTopupAfterDebit.
  • Auto-reply SMS uses the normal outbound SMS billing/store path. It is not platform-paid in the current implementation.

Twilio status callbacks (outbound)

  • URL: ${SERVER_URL}/api/twilio/sms-status (see lib/twilio.ts when sending SMS).
  • Updates SmsMessage.status, errorCode, errorMessage, and timestamps for delivered/failed.
  • shouldApplySmsStatusUpdate (lib/sms-status-precedence.ts): ignores updates that would downgrade progress (e.g. late sent after delivered) or replace a terminal failure with a non-terminal status.
  • If the SmsMessage row is not found yet, the handler retries briefly (race with send transaction).

Dashboard routes and product rules

  • Inbox / compose: /dashboard/sms — send uses POST /api/sms/send with a user-owned from-number and US/CA destination. UI copy explicitly labels the sender dropdown as "From (purchased Ringvoo number)" so verified custom caller IDs are not implied to be valid SMS senders.
    • When available balance is negative, the page shows a warning banner with a Buy credits link and floor-skip counters (negative_balance_floor skips over 24h/7d).
  • Logs: /dashboard/sms/logs — reads GET /api/sms/logs (pagination query params as implemented).
  • Usage: /dashboard/sms/usage redirects to /dashboard/sms (usage data may still exist via GET /api/sms/usage for future admin/analytics).
  • Alias: /dashboard/messages redirects to /dashboard/sms.
  • No owned number: The inbox surfaces a CTA when the user has no TwilioNumber; displayed monthly number price copy uses RINGVOO_NUMBER_MONTHLY_PRICE_USD (see lib/ringvoo-number-price.ts), unrelated to per-SMS segment billing.

Representative API routes

Route Role
POST /api/sms/send Authenticated outbound SMS
GET/POST /api/sms/conversations List/create conversations
GET /api/sms/conversations/.../messages Thread messages
POST /api/sms/conversations/.../read Mark read
GET /api/sms/logs SMS log feed
GET /api/sms/usage Usage aggregates (dashboard redirect; API may remain for tooling)
POST /api/twilio/sms/inbound Twilio inbound SMS webhook
POST /api/twilio/sms-status Twilio outbound message status
GET/POST /api/twilio/sync-sms Protected inbound SMS recovery / backfill from Twilio REST

SMS environment variables

Documented in .env.example; used by lib/billing/sms-config.ts and wallet inbound logic:

Variable Role
SMS_OUTBOUND_MARKUP_MULTIPLIER (M) for outbound retail at import (default 2.0)
SMS_INBOUND_MARKUP_MULTIPLIER (M) for inbound retail at import (default 2.0)
SMS_OUTBOUND_MINIMUM_FLOOR_USD (F) per segment, outbound (default 0.0100)
SMS_INBOUND_MINIMUM_FLOOR_USD (F) per segment, inbound (default 0.0050)
SMS_BILLING_CURRENCY Stored currency label (default USD)
SMS_ALLOW_NEGATIVE_BALANCE Inbound: allow wallet to go negative when debiting (default on)
SMS_NEGATIVE_BALANCE_LIMIT Inbound: do not debit past this balance floor (default -5)
SMS_INBOUND_AUTO_REPLY_ENABLED Enable/disable inbound auto-reply SMS (default on)
SMS_INBOUND_AUTO_REPLY_TEMPLATE Auto-reply body; supports {{number}} placeholder for the purchased Ringvoo number

Logging toggles for Twilio SMS (see lib/twilio-logging.ts): TWILIO_SMS_LOG_INBOUND, TWILIO_SMS_LOG_OUTBOUND, TWILIO_SMS_LOG_STATUS.

Dashboard copy (not per-message billing)

  • RINGVOO_NUMBER_MONTHLY_PRICE_USD — label for “starting at $X/mo” style messaging when prompting users to get a number (lib/ringvoo-number-price.ts). Does not affect SMS segment math.

Pricing and billing rules

SMS uses a separate model (per-segment rows, dual inbound/outbound markup/floors). See SMS (Twilio Messaging).

Retail multiplier

We bill customers retail = Twilio carrier cost × multiplier.

  • Env: RINGVOO_VOICE_RETAIL_MULTIPLIER
  • Default: 2
  • Code: getVoiceRetailMultiplier() and splitTwilioAndRetailCharge() in lib/billing/voice-pricing.ts

How we fetch carrier pricing (Twilio Pricing API)

We use Twilio Pricing API to fetch voice outbound prefix pricing by ISO country:

  • Function: fetchVoiceCountryPricing({ isoCountry }) in lib/billing/twilio-voice-pricing.ts
  • Uses Twilio SDK: client.pricing.v2.voice.countries(isoCountry).fetch()
  • Normalizes snake_case vs camelCase fields because Twilio SDK responses vary:
    • outbound_prefix_prices vs outboundPrefixPrices
    • destination_prefixes vs destinationPrefixes
    • current_price vs currentPrice
  • Caches results for 6 hours in-memory (per server instance):
    • CACHE_TTL_MS = 6h
    • cache key is versioned (CACHE_KEY_VERSION) so old normalized shapes don’t “poison” cache.

Why max duration uses the highest rate

We intentionally compute call time limits using the highest per-minute retail rate within a destination country:

  • Function: getMaxRetailRatePerMinuteUsdForIsoCountry() (lib/billing/twilio-voice-pricing.ts)

Reason:

  • Twilio voice pricing can vary by destination prefixes within a country.
  • If we computed max duration using an average or lowest rate, we could allow a call that exceeds wallet balance when routed through a more expensive prefix.
  • Using the maximum is conservative and prevents negative balances.

Marketing/preview uses the lowest “starting at” rate:

  • getMarketingStartingRetailRatePerMinuteUsd() (lib/billing/twilio-voice-pricing.ts)

Max duration math (safeMaxSeconds)

Ringvoo uses computeMaxDurationDecision() / computeMaxDurationDecisionFromRate() in lib/call-max-duration.ts as a shared “seconds you can afford at this $/min” primitive:

  • maxAllowedSeconds = floor(userBalanceUsd / ratePerMinuteUsd * 60)
  • safeMaxSeconds = max(0, maxAllowedSeconds - safetyBufferSeconds)
  • defaults: safetyBufferSeconds = 12, minAllowedSeconds = 60 (callers can override)

Where those defaults matter today

  • Some older/generic callers may still use the defaults above.
  • TwiML low-balance stepping (computeVoiceDialTimeLimitSeconds in lib/voice-dial-time-limit.ts) overrides to minAllowedSeconds = 1, safetyBufferSeconds = 0, then applies the step list (RINGVOO_LOW_BALANCE_TIME_LIMIT_STEPS_SECONDS).

Max-duration preview (GET /api/twilio/voice/max-duration-preview) does not subtract the 12s/60s buffer anymore. It computes:

  • maxAllowedSeconds = floor(walletBalanceUsd / conservativeRatePerMinUsd * 60) (member quota blocks may zero the effective balance first)
  • when lowBalanceMode is true: safeMaxSeconds = min(pickStep(maxAllowedSeconds), RINGVOO_VOICE_MAX_CALL_SECONDS cap)
  • when lowBalanceMode is false: safeMaxSeconds = min(maxAllowedSeconds, cap)

Worked max-duration example

Assume low balance mode is active and the configured steps are the default 60,120,180.

Inputs:

  • wallet balance = 0.5933
  • conservative retail estimate = 0.189/min (same class of number as TwiML admission estimatedCostPerMinuteUsd — country max in getOutboundVoiceEstimateForIso examples; live PSTN uses prefix getOutboundVoiceEstimateForDestination when applicable)

Math:

  1. maxAllowedSeconds = floor(0.5933 / 0.189 * 60) = 188
  2. Low-balance stepping picks the largest step ≤ 188180s
  3. TwiML <Dial timeLimit> becomes min(180, RINGVOO_VOICE_MAX_CALL_SECONDS clamp) (see outbound flow)

Normal mode (not low balance): <Dial timeLimit> is the long env cap (RINGVOO_VOICE_MAX_CALL_SECONDS); admission is still wallet.balance > 0 before Twilio connects.

Estimator alignment: getOutboundVoiceEstimateForIso() is still used where only a country is known (marketing / max-country paths): max carrier in country × RINGVOO_VOICE_RETAIL_MULTIPLIER. Live TwiML outbound PSTN uses getOutboundVoiceEstimateForDestination (longest prefix × multiplier). Inbound TwiML uses getInboundVoiceEstimateForIso({ isoCountry, dialedToE164, callerFromE164 }) (longest prefix on DID with caller origination, else country startingAt × multiplier). There is no additional × 1.2 layer in the estimator module.

Voice admission, low balance mode, CallSession, and policy knobs

This replaced persistent CallHold reservations.

Admission (evaluateVoiceCallLiquidity in lib/voice-call-validation.ts):

  • Hard block when wallet.balance ≤ 0 (no calls, inbound or outbound, until the wallet is positive again).
  • creditLimitUsd is always 0 (computeVoiceCreditLimitUsd in lib/billing/voice-wallet-credit.ts) — no shared credit / negative-balance extension for voice.

Low balance mode (isVoiceLowBalanceMode in lib/billing/voice-wallet-credit.ts):

  • If lastPurchaseAmount > 0: low balance when wallet.balance ≤ lastPurchaseAmount × RINGVOO_LOW_BALANCE_THRESHOLD_PERCENT (default 0.02).
  • If there is no purchase history (lastPurchaseAmount = 0): any wallet.balance > 0 is treated as low balance mode (protects welcome credit / first-call scenarios).

CallSession fields vs pre-call gating

  • evaluateVoiceCallLiquidity still returns activeSessions, but estimatedExposureUsd is currently 0 (concurrency is not priced into the pre-call gate).
  • CallSession remains important for estimated settlement when Twilio price is late and for binding the billable SID over the parent/child leg lifecycle.

Dialer / API “available” balance: getAvailableBalanceUsd() is hold-free — it returns wallet.balance as callable. GET /api/twilio/voice/max-duration-preview uses wallet.balance as its budget basis (then applies org member quota gates separately).

Late Twilio price: handled by CallSettlement provisional debit + delta, not by shrinking a hold.

Worked low-balance time limit stepping

Same numbers as Worked max-duration example, but emphasizing the step picker:

  • budget-based seconds = 188
  • steps = 60,120,180 → chosen = 180
  • if budget-based seconds were 45, the product policy still returns the smallest step (60) from pickLowBalanceStepSeconds (then clamped by RINGVOO_VOICE_MAX_CALL_SECONDS)

If Twilio later reports a different final carrier cost, outbound (and inbound when RINGVOO_INBOUND_VOICE_PRICE_SOURCE=twilio) may reconcile via CallSettlement / cost sync (see Voice call sessions). Default inbound CSV billing does not chase Twilio child-leg price down.

Wallet debit + refunds

Wallet logic lives in lib/wallet.ts.

Debit

  • debitWallet():
    • runs in a serializable transaction by default
    • checks wallet balance first
    • creates a Transaction row with type=CALL or type=SMS
    • decrements Wallet.balance
    • idempotent: if referenceId already exists for the same source, it treats it as already debited.
    • supports onInsufficient: "skip" used by call settlement to avoid throwing during asynchronous callbacks.

Call settlement

upsertCallAndSettleWallet() in lib/call-billing.ts is the “billing brain”:

  • Parses terminal status; failed-like terminals force zero retail/carrier so mis-attributed Twilio Price on parent legs never bills.
  • Outbound completed: if Twilio webhook/REST provides a Price, default path uses twilio_webhook (carrier × RINGVOO_VOICE_RETAIL_MULTIPLIER via splitTwilioAndRetailCharge). If price is missing, CSV longest-prefix carrier × ceil(billable seconds / 60) × multiplier (voicePricingSource csv_prefix; may set needsVoiceReconcile for later REST reconcile).
  • Inbound completed (default): when RINGVOO_INBOUND_VOICE_PRICE_SOURCE is unset or not twilio, Twilio’s Price on the browser child leg is ignored for retail (it is often far below your table). Charges use the same CSV longest-prefix rule as admission: destination = resolved DID To, origination = PSTN From, with fallback to country startingAt; voicePricingSource csv_prefix, needsVoiceReconcile typically false. Set RINGVOO_INBOUND_VOICE_PRICE_SOURCE=twilio to bill inbound from Twilio like outbound.
  • Computes:
    • twilioCost (carrier side stored on Call / settlement — for CSV paths this is the synthetic carrier aligned to the retail split)
    • retailCharge (customer debit)
  • Writes/updates Call row with:
    • twilioCost if known
    • cost only once debited (or explicit zero settled cases)
    • voice_pricing_source, initial_voice_pricing_source, needs_voice_reconcile where applicable
  • Debits wallet when:
    • status is completed
    • and retailCharge is known and > 0
  • CallSettlement row for idempotency; estimated path when completed but price missing (see Voice call sessions).
  • Binds CallSession billable SID; ends active sessions on terminal.
  • Duration fallback chain: Twilio duration → internal timestamps / CallSession / existing Call row.
  • May operate on provisional Call rows (client-ended-pending-twilio) until child SID is known.

Refunds

applySystemRefundIfEligible() credits back the full charge in some cases:

  • Always refunds for terminal failures:
    • failed, busy, no-answer, canceled
  • Also refunds “short calls” under a threshold (default 5 seconds) unless:
    • status is completed AND Twilio carrier cost was actually positive
    • this prevents subsidizing carrier charges for answered-but-immediately-hung-up calls

Refunds are idempotent via Transaction(referenceId=callSid, source="refund").

Idempotency & consistency model

This system is explicitly built for retries and race conditions.

  • DB isolation: billing uses Serializable Prisma transactions in critical spots; call-status retries on transient write conflicts after pg_advisory_xact_lock on the billable SID (lib/pg-advisory-lock.ts).
  • Idempotent debits: unique index on Transaction(referenceId, source) ensures:
    • repeated callbacks for the same callSid do not double-charge
  • Idempotent call upsert: Call row is upserted by twilioCallSid.
  • CallSettlement: unique on callSid; estimated → final uses delta transactions with distinct reference ids (:estimated, :final-delta, …).
  • Parent-to-child recovery: provisional parent-only calls can later be upgraded with the child twilioCallSid without creating duplicate debits.

Stripe wallet funding

Wallet is USD. Ringvoo credits the wallet only from verified Stripe webhooks (POST /api/webhooks/stripe); the browser return URL is UX only.

Organization wallet vs Stripe Customer (important)

Ringvoo distinguishes where credits land (your Wallet row — personal or organization) from which Stripe Customer is charged (cus_…). App wallet scope is always from the httpOnly cookie ringvoo_active_context (RINGVOO_CONTEXT_COOKIE in lib/context.ts); see Active billing context.

Concept Personal context Organization context
Wallet credited User’s personal Wallet Org’s Wallet (Wallet.organizationIdOrganization)
Manual “Buy credits” Checkout POST /api/billing/checkout-session + resolveRequestContexttargetWalletId, metadata ringvooWalletScope: personal Same API; ringvooWalletScope: org, ringvooTargetWalletId = org wallet, ringvooOrganizationId on session + PaymentIntent metadata. Owner/admin only (403 for members); /dashboard/buy-credits redirects members to /dashboard/phone.
Stripe Customer for Checkout User.stripeCustomerId via getOrCreateStripeCustomer() (lib/stripe/customer.ts) Organization.stripeCustomerId via getOrCreateStripeCustomerForOrganization({ organizationId }) (lib/stripe/org-customer.ts). Customer name = org name; email = owner’s email (first create). Metadata on Stripe: ringvooOrgId, ringvooOwnerUserId.
Default PM after wallet Checkout syncDefaultPaymentMethodFromWalletCheckout → updates User (stripeCustomerId, stripeDefaultPaymentMethodId) Same handler → updates Organization (not User) when ringvooWalletScope === org.
Tax ID on checkout POST User.taxIdOrVat Organization.taxIdOrVat (app/api/billing/checkout-session/route.ts branches on ContextScope.ORG). Session metadata still includes taxIdOrVat for Stripe receipts.
Org auto top-up (off-session) Thresholds on Organization. PaymentIntent uses Organization.stripeCustomerId + Organization.stripeDefaultPaymentMethodId when both are set (lib/auto-topup.ts runMaybeTriggerAutoTopupOrg). Legacy: if the org has never saved a card on the org Customer, falls back to owner’s User.stripeCustomerId + User.stripeDefaultPaymentMethodId until the next org Checkout/setup. PI metadata: ringvooTargetWalletId, ringvooWalletScope: org, optional ringvooOrganizationId.
mode: setup (save card, no charge) createAutoTopupPaymentMethodSetupSession → user Customer Org scope (POST /api/billing/setup-payment-method with cookie): same session helper with organizationId → org Customer; webhook handleSetupCheckoutSessionCompleted updates Organization (PM + auto top-up fields) when metadata includes ringvooOrganizationId.
First-purchase reward + UserCoupon mark-used Runs for personal wallet sessions Skipped when ringvooWalletScope === org (lib/stripe/webhook-handlers.ts).

Buy credits UX (avoid wrong tier / flash): Server page app/dashboard/buy-credits/page.tsx passes initialBillingContext (resolveRequestContext) into BuyCreditsClient so enterprise vs default tiers and default $500 vs $20 preset match first paint before GET /api/user/wallet. Workspace switch while on Buy credits resets order summary amounts when effectiveVariant changes (personal ↔ enterprise) unless ?amount= is present — see BuyCreditsClient (prevEffectiveVariantRef effect).

Workspace switch performance: UserMenu.switchWorkspace POSTs context, optimistically updates activeCtx (no extra GET /api/context/active after switch), dispatches ringvoo-context-changed once; WalletBalanceProvider runs a single GET /api/user/wallet (removed duplicate wallet.refresh() before the event and removed Sidebar’s duplicate listener). getRingvooContextHeaders() does not send x-ringvoo-context from sessionStorage so the cookie stays authoritative (fixes brief wrong balance after back-navigation from Stripe).

Code pointers: lib/stripe/checkout-session.ts, lib/stripe/org-customer.ts, lib/stripe/setup-checkout-session.ts, lib/stripe/webhook-handlers.ts (syncDefaultPaymentMethodFromWalletCheckout, applyCheckoutUserSettings, handleSetupCheckoutSessionCompleted), lib/auto-topup.ts, app/api/billing/checkout-session/route.ts, app/api/billing/setup-payment-method/route.ts, app/api/user/billing/auto-topup/route.ts, lib/client-billing-context.ts, components/dashboard/UserMenu.tsx, components/dashboard/WalletBalanceContext.tsx.

Full API surface for switching workspace and org membership lists: Context API (/api/context/active) under Multi-tenant workspace.

  • Source of truth: RINGVOO_CONTEXT_COOKIE (ringvoo_active_context) — JSON { type: "personal" \| "org", id }httpOnly, set by POST /api/context/active. resolveRequestContext (lib/context.ts) prefers request header x-ringvoo-context if present, else cookie. Client fetches must not send a stale x-ringvoo-context from sessionStorage for wallet/billing routes: getRingvooContextHeaders() returns {} so the server uses the cookie only (lib/client-billing-context.ts).
  • sessionStorage ringvoo_ctx_header: Still written on context switch and on WalletBalanceProvider mount (GET /api/context/active) for code that reads it synchronously (e.g. Twilio connect params in DialerPreview), not for overriding billing APIs.
  • Switching workspace: components/dashboard/UserMenu.tsx — POST, update sessionStorage + activeCtx optimistically, window.dispatchEvent("ringvoo-context-changed"); listeners refresh wallet, Buy credits auto-topup fetch, etc.

Manual top-up (Buy credits)

  1. User opens app/dashboard/buy-credits and clicks Secure checkout (optional Tax ID / VAT is sanitized via lib/tax-id.ts and saved to User.taxIdOrVat (personal) or Organization.taxIdOrVat (org) on each checkout POST, and copied into Checkout session metadata as taxIdOrVat — Ringvoo does not use it to calculate tax unless you enable Stripe automatic tax later).

  2. Minimum wallet top-up is $5 USD (enforced in POST /api/billing/checkout-session via validateWalletTopUpAmount in lib/stripe/checkout-session.ts). The Buy credits UI clamps under-minimum custom amounts on blur and before starting checkout; see Dashboard billing UI.

  3. POST /api/billing/checkout-session creates a Stripe Checkout Session (mode: payment) with customer = personal getOrCreateStripeCustomer or org getOrCreateStripeCustomerForOrganization (see Organization wallet vs Stripe Customer). Dynamic line_items (currency: usd, price_data), payment_intent_data.setup_future_usage: off_session (card saved on that Customer), payment_intent_data.receipt_email (user email for Stripe receipts), payment_intent_data.description + line item product_data.name = human-readable Call Credits × {usd} (lib/stripe/call-credits-stripe-copy.ts), payment_intent_data.metadata: type: "topup", credits (= walletCreditUsd string), userId, existing keys (ringvooUserId, kind, walletTopupType, …). Session metadata: ringvooUserId, userId, walletCreditUsd, taxIdOrVat, ringvooWalletScope, ringvooTargetWalletId, ringvooOrganizationId (when org), auto top-up fields.

  4. User pays on Stripe-hosted Checkout; success redirect includes checkout=success (wallet may still be updating until webhooks finish).

  5. For mode: payment sessions with payment_status = paid, webhooks checkout.session.completed / checkout.session.async_payment_succeeded retrieve the session (expanded payment_intent, setup_intent as needed), then:

    • In a DB transaction (when the Stripe event id is new): creditWallet() + StripeWebhookEvent insert.
    • Then syncDefaultPaymentMethodFromWalletCheckout: sets Stripe Customer invoice_settings.default_payment_method and mirrors stripeCustomerId + stripeDefaultPaymentMethodId on User (personal) or Organization (org) when a payment method is present.
    • Then applyCheckoutUserSettings: updates auto top-up toggles and amounts on User or Organization per ringvooWalletScope. Does not clear the saved PM when auto top-up is off.
  6. creditWallet uses source = stripe_checkout_session and referenceId = session.id (idempotent with @@unique([referenceId, source])).

  7. fundingSource on the Transaction row is manual_checkout; TOPUP rows store Stripe audit fields; stripeCustomerId on the transaction reflects the charged Customer (personal or org).

  8. After wallet credit + idempotent event handling, first-purchase reward and promo usage (see Promotion codes — wallet top-up): issueFirstPurchaseRewardIfEligible, then either markUserCouponUsedFromCheckoutSession (metadata ringvooUserCouponId) or markUserCouponUsedFromCheckoutSessionDiscounts (promo entered on Hosted Checkout only). Coupon marking runs only when the session is a wallet top-up (walletTopupType or legacy walletCreditUsd metadata). Org sessions skip first-purchase + coupon mark-used paths when ringvooWalletScope === org.

Hosted Checkout (wallet) — line item, branding, tax

lib/stripe/checkout-session.ts sets:

  • line_items[].price_data.product_data: name = Call Credits × {qty} (same dollars as credits, 2 dp when needed); description = Ringvoo call credits (secondary line on Checkout / receipts).
  • payment_intent_data: description matches that line title; receipt_email = buyer email so Stripe-hosted successful payment emails target the same address; metadata includes type: "topup", credits, userId (plus legacy ringvooUserId, kind, walletCreditUsd, wallet scope, org id when applicable — see lib/stripe/constants.ts).
  • branding_settings.display_name: Ringvoo (top label on Hosted Checkout for that session).
  • automatic_tax.enabled: false (no Stripe automatic tax line unless we opt in later).
  • adaptive_pricing.enabled: from isStripeCheckoutAdaptivePricingEnabled() in lib/stripe/checkout-adaptive-pricing.ts (STRIPE_CHECKOUT_ADAPTIVE_PRICING — see Checkout Adaptive Pricing). Same helper is used for wallet, virtual-number subscription, and setup Checkout sessions so the API explicitly sets enabled/disabled (omitting it lets Stripe fall back to Dashboard defaults).

Shared copy helpers: lib/stripe/call-credits-stripe-copy.ts (callCreditsPaymentDescription, formatCallCreditsQuantity).

Each new top-up uses a new Checkout Session URL; line items are fixed at session creation.

Stripe customer emails, receipt copy, and test vs live

  • Who sends mail: Stripe sends payment receipts, refund receipts (when enabled), and Billing-related messages when your Stripe Customer emails toggles are on and Stripe has a recipient. Ringvoo’s Resend emails (auth, support, etc.) are separate — do not confuse the two.
  • Dashboard (not code): Business name, support email / phone in the receipt footer (e.g. info@ringvoo.com), and Customer emails on/off live under Stripe Dashboard → Settings (public details + email preferences). This doc does not duplicate Dashboard steps — keep them aligned with production branding.
  • API (code): We set receipt_email and human-readable description / line items so Stripe’s PDF-like emails show Call Credits × …, Ringvoo Phone Number - Monthly Fee ($…), etc. Wallet updates remain webhook-only — never from the client.
  • Test / sandbox vs live: Stripe may generate receipts in the Dashboard (and you can Send receipt manually) but automatic email delivery to arbitrary addresses is often limited in test mode. For end-to-end inbox verification, use live keys with a real small charge, a verified-domain email, or a team-member inbox as Stripe allows in your account. Do not assume every Gmail will receive automated test receipts.

Promotion codes — wallet top-up

Stripe allows either pre-applied discounts or allow_promotion_codes on a Checkout Session — not both.

Flow Session params Hosted Checkout UI
Promo validated on Buy credits (UserCoupon via validateUserCouponForManualWalletTopup in lib/billing/user-coupon.ts) discounts: [{ promotion_code: … }] + metadata ringvooUserCouponId No separate “Add promotion code” field (discount already applied).
No promo before redirect allow_promotion_codes: true Customer can enter any valid Stripe Promotion Code on Hosted Checkout (e.g. marketing campaigns created in the Stripe Dashboard). Virtual number subscription Checkout does not use allow_promotion_codes (no promo field on that flow).

Wallet credit amount: session metadata walletCreditUsd is always the full top-up in USD. If a promotion reduces what Stripe charges, the wallet is still credited the full walletCreditUsd (same as the pre-applied UserCoupon path).

Webhooks — marking UserCoupon used (lib/stripe/webhook-handlers.ts, after successful wallet top-up):

  • If ringvooUserCouponId (or CHECKOUT_META_RINGVOO_USER_COUPON_ID) is in metadata → markUserCouponUsedFromCheckoutSession.
  • Else → markUserCouponUsedFromCheckoutSessionDiscounts: reads session.discounts and matches promotion_code to UserCoupon.stripePromotionCodeId for that user.

Marketing-only codes (Dashboard): Coupon + Promotion Code created only in Stripe (no UserCoupon row) can still discount Checkout. Ringvoo will not update UserCoupon for those. Wallet credit still follows metadata.walletCreditUsd.

STRIPE_FIRST_PURCHASE_REWARD_COUPON_ID: Stripe Coupon id (coupon_…) used as the template when the app creates a per-user Promotion Code after the first manual wallet top-up (issueFirstPurchaseRewardIfEligiblestripe.promotionCodes.create in lib/billing/user-coupon.ts). It is not a single shared customer-facing code. Campaign codes (e.g. created manually in the Dashboard) are separate.

Virtual number checkout is documented in Virtual number purchase (Stripe subscription + Twilio) (lib/stripe/virtual-number-checkout.ts). Promotion codes are not enabled on Hosted Checkout for that flow.

Code: app/api/billing/checkout-session/route.ts, lib/stripe/checkout-session.ts, lib/stripe/webhook-handlers.ts, lib/stripe/customer.ts, lib/stripe/org-customer.ts, lib/tax-id.ts, lib/wallet.ts, lib/billing/user-coupon.ts.

Dashboard billing UI (Buy credits, Settings)

Modules

  • app/dashboard/buy-credits/BuyCreditsClient.tsx — wallet top-up + optional auto top-up for this checkout + Tax ID / VAT + promo field.
  • app/dashboard/settings/SettingsClient.tsx — wallet balance, auto top-up persistence (PATCH), link to Buy credits / billing.
  • /dashboard/billing — credits-only history + invoice PDFs; see Billing portal: credits and invoices. Entry from header user menu (UserMenu), not the sidebar.
  • Defaults when the DB has no saved threshold/amount yet: lib/billing-defaults.ts (DEFAULT_AUTO_TOPUP_THRESHOLD_USD, DEFAULT_AUTO_TOPUP_AMOUNT_USD).

$5 minimum (wallet + auto top-up charge amount)

  • Server: lib/stripe/checkout-session.ts (MIN_USD = 5, validateWalletTopUpAmount, auto top-up amount checks on session creation).
  • Buy credits: custom wallet amount and optional auto top-up amount show inline validation (red border + message) when 0 < amount < 5; blur and Secure checkout clamp to $5 before calling the API.
  • Settings: auto top-up amount uses the same minimum in the Input error state while invalid; blur clamps to $5; Save clamps a positive under-minimum value to $5 instead of rejecting (empty or non-positive amounts still show field errors). Threshold remains “≥ 0” and required when auto top-up is on.

Buy credits — other UX

  • Server-seeded billing context: app/dashboard/buy-credits/page.tsx runs resolveRequestContext and passes initialBillingContext into BuyCreditsClient so tier (default vs enterprise) and default preset ($20 vs $500) match the active workspace on first paint. GET /api/user/wallet uses cookie-only context (credentials: "same-origin"; getRingvooContextHeaders() does not send x-ringvoo-context from sessionStorage) — see Active billing context. Switching personal ↔ org resets order summary amounts when the billing tier changes (unless ?amount= pins a custom amount).
  • Preset amount tiles show tier labels (e.g. Starter, Most popular) inside the tile layout (not absolutely positioned pills) so they do not overlap the custom-amount / validation area.
  • Optional Tax ID / VAT heading treats “(optional)” with the same text color as the title.
  • Contextual copy when auto top-up is toggled vs server state includes a link to Settings where users can persist auto top-up without going through wallet checkout when appropriate.
  • The page does not show a separate “Stripe has a saved payment method…” line; GET /api/user/billing/auto-topup still returns hasSavedPaymentMethod for Settings (and any other consumer).
  • Order summary (sidebar): Pay Ringvoo + amount to pay, bordered table of line item (Call Credits × …, matching the server-generated Checkout line title), Subtotal, optional discount row when a UserCoupon is validated on the page, Total due (no separate “Taxes” row for wallet top-up). Promo can be validated before Secure checkout; additional codes can be entered on Hosted Checkout when allow_promotion_codes is used (see Promotion codes — wallet top-up).

Settings — other UX

  • Workspace-aware shell: in org workspace the page title becomes Organization settings and surfaces an Organization account card (org id, role badge, billing owner for non-member view, team size, created date). In personal workspace it shows personal account info plus the Start a team workspace CTA.
  • Role-gated sections in org scope: OWNER/ADMIN can access Manage team, org wallet funding links, and organization billing controls; plain MEMBER sees usage-oriented messaging and cannot view/edit org auto top-up (GET/PATCH /api/user/billing/auto-topup returns 403 for member role in org context).
  • Available balance loading state: while wallet fetch is pending and no initial value is present, the amount renders an ellipsis () and then updates from /api/user/wallet.
  • The short green tagline under “Auto top-up when balance is low” was removed so it does not sit above the amount/threshold fields and compete with inline validation.
  • When auto top-up is on and hasSavedPaymentMethod is true, notice copy is scope-aware (personal vs org wallet) and keeps the same minimum amount constant for that scope (minWalletTopUpUsdForScope).
  • Context-change behavior: Settings listens for ringvoo-context-changed, then re-fetches wallet, auto top-up payload, and rewards to avoid stale personal/org data after switching workspaces.
  • Org owner actions: owner-only controls include transfer ownership (POST /api/orgs/[orgId]/transfer) and delete organization (DELETE /api/orgs/[orgId]). On successful delete, UI switches context back to personal via POST /api/context/active and broadcasts context change before redirecting.

Billing portal: credits and invoices

  • Route: /dashboard/billingBillingPortalClient (components/dashboard/billing/BillingPortalClient.tsx).
  • Workspace: Uses resolveRequestContext via the same httpOnly ringvoo_active_context cookie as other wallet APIs. Personal and organization ledgers are separate: the UI shows one active workspace at a time (chip: Personal billing vs Organization · {name} from GET /api/context/active). ringvoo-context-changed refetches transactions.
  • List scope: GET /api/user/wallet/transactions?billing=1 returns only TransactionType.TOPUP rows (manual checkout, auto top-up, welcome credit, call goodwill credits with source = "refund", etc.). Omit billing=1 for the full ledger (e.g. WalletActivityCard). The API returns at most the 25 most recent rows (take: 25); the billing UI shows copy like “Showing last 25 credit events” so the cap is explicit.
  • Invoices: Get invoice → modal → POST /api/billing/invoice → PDF (pdf-lib, lib/billing/invoice-pdf.ts). Only TOPUP rows; amounts may be shown as credit or debit depending on sign (e.g. goodwill line items). Issuer contact line prefers SUPPORT_INBOX_EMAIL (fallback support@ringvoo.com in code). Invoice number on the PDF is RV- + suffix derived from the Transaction.id (not a separate stored invoice row — same transaction always gets the same number).
  • PDF styling: Header embeds public/favicon.png as the logo when readable; mint (#00e5a0) / navy (#0a0e1a) accents and light panels match marketing brand (lib/billing/invoice-pdf.ts).
  • Navigation: Billing is linked from the header user menu (UserMenu) only — not in the sidebar.
  • Org members: Plain MEMBER can open Billing and see read-only org wallet credit history; Get invoice is hidden unless canDownloadWorkspaceInvoice passes (privileged org roles + always personal).

Stripe card refunds (super-admin API + webhooks)

When money is returned to the customer’s card for a wallet PaymentIntent, the Ringvoo wallet must move in lockstep. Do not debit the wallet from the browser or from the admin HTTP handler alone — Stripe webhooks are the source of truth.

Super-admin UI

  • Route: /admin/billing (app/admin/billing/page.tsx) — lists Payment rows (wallet top-ups tracked in Payment / Transaction).
  • Action: RefundPOST /api/admin/payments/refund with paymentIntentId and optional amountUsd / reason (app/api/admin/payments/refund/route.ts).
  • Server: lib/admin/stripe-refund-request.tsstripe.refunds.create with idempotency key refund_admin_{pi}_{cents}; Payment.statusrefund_pending; last refund id stored for support.

Before the Refunds API call: the handler sets receipt_email on the PaymentIntent from User.email so Stripe’s refund email rules can target the payer (same pattern as top-up receipts).

Webhooks (wallet debit)

  • refund.created and charge.refunded: lib/stripe/webhook-handlers.ts dispatches to applyStripeRefundToLedger (lib/stripe/refund-webhook.ts).
  • Creates a STRIPE_REFUND ledger row, debitWalletForStripeRefund, updates Payment.refundedAmount / status (refunded | partial_refund). Idempotent per Stripe refund.id / event id + serializable transaction retries for races.

Operational caveat: Refunds created only in the Stripe Dashboard (bypassing Ringvoo) still emit webhooks — if your POST /api/webhooks/stripe endpoint receives them, the wallet should still align. If webhooks do not reach the app (misconfigured URL/secret), the Payment row and wallet can drift — fix connectivity or handle support adjustments manually.

Do not confuse with call goodwill wallet credits: applySystemRefundIfEligible uses Transaction.source = "refund" on TOPUP and adds positive credit for eligible short/failed calls — that is call billing, not Stripe card refunds.

Save card only (no wallet charge)

For users who want auto top-up without an immediate top-up:

  1. UI (e.g. Settings or Buy credits) calls POST /api/billing/setup-payment-method with threshold/amount and returnTo (settings | buy-credits). Route resolves resolveRequestContext; org + privileged → passes organizationId into createAutoTopupPaymentMethodSetupSession so the Setup session uses the organization Stripe Customer (lib/stripe/setup-checkout-session.ts metadata: ringvooOrganizationId, ringvooWalletScope: org).
  2. Creates Checkout mode: setup with metadata kind = auto_topup_setup_checkout and auto top-up amounts.
  3. On checkout.session.completed for mode: setup, webhook handler handleSetupCheckoutSessionCompleted (no creditWallet) attaches the SetupIntent’s payment method to the Customer, sets User or Organization PM + auto top-up fields depending on ringvooOrganizationId in metadata.
  4. Redirect query billing_setup=success | billing_setup=canceled depending on returnTo.

Checkout Adaptive Pricing (local currency UI)

Stripe can show a local currency alongside USD when Adaptive Pricing is enabled. Ringvoo sets adaptive_pricing.enabled from isStripeCheckoutAdaptivePricingEnabled() in lib/stripe/checkout-adaptive-pricing.ts, driven by STRIPE_CHECKOUT_ADAPTIVE_PRICING (true / 1 / yes → on; unset or any other value → off, default). Applied to wallet Checkout (lib/stripe/checkout-session.ts), virtual number subscription Checkout (lib/stripe/virtual-number-checkout.ts), and setup Checkout (lib/stripe/setup-checkout-session.ts). Wallet credits remain USD from metadata.

Virtual number purchase (Stripe subscription + Twilio)

Users buy a US or Canada local number with Voice + SMS (incoming), billed monthly through Stripe subscription Checkout. Provisioning uses Twilio (searchAvailableNumbers, purchase incoming number, link to TwilioNumber).

UI (app/dashboard/numbers, components/dashboard/numbers/NumbersPageClient.tsx)

  • Country, optional area code, optional contains digits feed GET /api/numbers/search — see Search filters below.
  • While the search API runs: “Fetching numbers…” line with a pulsing indicator + skeleton placeholder rows.
  • Empty state when the API returns zero rows.
  • No numbered pagination in the UI: the API returns at most 50 numbers per request (lib/twilio/virtual-number-service.ts); area-code / digit filters usually keep lists short. Add Twilio paging later only if you need more than 50 per query.
  • Retry UX updates:
    • failed provisioning banner is red, includes warning icon, attention shake animation, and auto-scroll to ensure visibility
    • success assignment banner appears after retry success, auto-scrolls into view, then auto-dismisses (~10s)
  • Mobile/Chrome clickability safeguards were added for right-edge action buttons on long lists.

Dialer buy modal (dashboard phone)

  • Source: components/marketing/DialerPreview.tsx, app/dashboard/phone/page.tsx
  • Failed provisioning state now:
    • uses red warning styling + icon
    • shows: Your last number (+E164) purchase failed...
    • strips spaces in displayed attempted number for compact E.164 readability
    • uses attention animation when repeated retries fail
  • Retry success state now opens/keeps success UI reliably even in rapid failed→failed→success paths.
  • Buy modal price labels now use env-driven numberMonthlyPriceLabel (from RINGVOO_NUMBER_MONTHLY_PRICE_USD) instead of hardcoded $1.95.

Numbers page visibility flag (hide without delete)

  • New feature flag: ENABLE_NUMBERS_PAGE (default enabled).
  • When disabled:
    • /dashboard/numbers and /dashboard/numbers/success redirect to /dashboard/phone
    • Sidebar/UserMenu Numbers links are hidden
    • in-app “Buy a phone number” links that previously pointed to Numbers fallback to Phone
    • virtual-number checkout default return target falls back to Phone when not explicitly provided.

Search filters (GET /api/numbers/search)

Query param Behavior
country US or CA (required).
areaCode Optional. Live Twilio: passed to availablePhoneNumbers(…).local.list as areaCode. Test mode (ENABLE_TWILIO_TEST_MODE=true): fake inventory is filtered locally (3-digit NANP match or substring).
contains Optional digits only (non-digits stripped). Live: Twilio contains. Test mode: substring match on E.164 digits.

Stripe Checkout (lib/stripe/virtual-number-checkout.ts)

  • Flow: POST /api/numbers/create-checkout creates a VirtualNumberOrder, then createVirtualNumberCheckoutSession.
  • Mode: subscription.
  • Line items — dynamic price_data + product_data: The session does not use only the Dashboard Price id for the line item text. The code stripe.prices.retrieve(STRIPE_VIRTUAL_NUMBER_PRICE_ID) and builds line_items with price_data matching the catalog price’s currency, unit_amount, recurring, tax_behavior, and copies product.tax_code when present. product_data sets customer-visible titles for Stripe Checkout, invoices, and email line items (Dashboard catalog name/description alone cannot template per-number copy).
    • branding_settings.display_name: Ringvoo on this session.
    • Name: Ringvoo Phone Number - Monthly Fee ($X)X from RINGVOO_NUMBER_MONTHLY_PRICE_USD via lib/ringvoo-number-price.ts (ringvooPhoneNumberMonthlyStripeTitle()).
    • Description: Phone Number Monthly Fee — {display} (RINGVOO_PHONE_NUMBER_LINE_DESCRIPTION + NANP-spaced E.164 from formatE164ForDisplay() in lib/twilio.ts).
  • Catalog tradeoff: Each checkout creates new Stripe Product and Price objects (API behavior for inline product_data). product_data.metadata.ringvoo_virtual_number_checkout tags them. Webhooks and provisioning key off subscription metadata (feature: virtual_number, selectedNumber, ringvooOrderId, etc.), not the catalog price id.
  • subscription_data: description = Ringvoo Phone Number - Monthly Fee ($X) — {display} for portal / subscription context; provisioning sync updates Stripe subscription description after Twilio assigns the number (lib/stripe/virtual-number-webhook.ts). metadata mirrors session + order linkage.
  • metadata on the Checkout Session: same feature / user / order / selectedNumber fields for webhook routing.
  • allow_promotion_codes: not set — no promotion code field on Hosted Checkout for virtual numbers.
  • adaptive_pricing: from lib/stripe/checkout-adaptive-pricing.ts (see Checkout Adaptive Pricing).

Stripe Dashboard branding

Logo, colors, checkout background (e.g. #f9fafb or #ffffff to match Ringvoo’s light surfaces), public business name, and customer support lines on Stripe-generated emails are configured in Stripe Dashboard → Settings → Branding / Checkout / public business details, not in application code. API code sets branding_settings.display_name: "Ringvoo" on Checkout sessions where supported.

Webhooks & provisioning

  • lib/stripe/virtual-number-webhook.ts and lib/stripe/webhook-handlers.ts: when metadata.feature === virtual_number (STRIPE_METADATA_FEATURE_VIRTUAL_NUMBER in lib/stripe/constants.ts), complete provisioning (Twilio purchase, VirtualNumber + TwilioNumber, order status).
  • invoice.paid (virtual numbers): handleVirtualNumberInvoicePaid records the Stripe event.id in StripeWebhookEvent before side effects so duplicate deliveries are ignored (idempotent replays).
  • Test mode: resolveTwilioPurchaseE164 maps magic test numbers to Twilio’s test success line; see lib/twilio/virtual-number-service.ts.

Virtual number subscription billing (failed payments & Stripe cancellation)

  • Monthly amount (UX label): driven by RINGVOO_NUMBER_MONTHLY_PRICE_USD in lib/ringvoo-number-price.ts (defaults to 1.95 USD if unset/invalid). Stripe’s recurring charge uses the catalog Price behind STRIPE_VIRTUAL_NUMBER_PRICE_ID for unit_amount / recurring when building Checkout line_items — keep env and Dashboard price aligned with product intent.
  • Failed renewal (e.g. insufficient funds): Stripe emits invoice.payment_failed. handleVirtualNumberInvoicePaymentFailed (lib/stripe/virtual-number-webhook.ts) sets the matching VirtualNumber.status to past_due. customer.subscription.updated also syncs Stripe subscription status: Stripe past_due / unpaid → DB past_due.
  • In-app grace behavior: For SMS/voice eligibility, the codebase treats active and past_due the same (e.g. lib/virtual-number/subscription-guards.ts, lib/sms-send.ts, lib/sms-inbound.ts, outbound voice routes) so users are not cut off in-app immediately on the first failed charge while Stripe retries. Stripe’s retry timing and dunning live in the Stripe Dashboard (Smart Retries / subscription settings), not in this repo.
  • User-facing warning: An amber banner appears on Dashboard → Settings, in the Phone Numbers block (components/dashboard/settings/VirtualNumberSettingsSection.tsx) when any owned number is past_due. It does not use the Numbers page red banner — that banner is for Twilio provisioning failure after a paid checkout, not card decline.
  • Subscription canceled in Stripe: On customer.subscription.deleted, handleVirtualNumberSubscriptionDeleted runs: releaseIncomingNumber(vn.twilioSid) (Twilio — errors are logged; DB cleanup still proceeds), then a Prisma transaction deletes the VirtualNumber row and the linked TwilioNumber row. The user no longer has that number in Ringvoo after cancellation completes.

Localhost testing: test-provisioned vs real-provisioned virtual numbers

  • ENABLE_TWILIO_TEST_MODE=true is for deterministic local simulation of number search/purchase and provisioning outcomes.
  • In that mode, provisioning intentionally bypasses live Twilio purchase and persists simulated VirtualNumber.twilioSid values prefixed with PNTEST....
  • A row with PNTEST... is valid for UI/flow testing, but it does not prove live Twilio ownership/authorization for PSTN caller-id presentation.
  • For real end-to-end caller-id behavior on a destination phone:
    • set ENABLE_TWILIO_TEST_MODE=false
    • ensure TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN match the account that owns the number
    • ensure VirtualNumber.phoneNumber and TwilioNumber.phoneNumber are E.164 and aligned
    • ensure VirtualNumber.twilioSid is a real Twilio IncomingPhoneNumber SID (PN..., not PNTEST...)
  • Practical signal from field testing: if the same selected number appears on the callee (for example, non-US destination like UAE), the virtual-number path and Twilio authorization are working end-to-end.
Route Role
GET /api/numbers/search Search available numbers (Twilio or test inventory)
POST /api/numbers/create-checkout Create Stripe subscription Checkout + VirtualNumberOrder
POST /api/numbers/retry-provisioning Retry Twilio assign after a failed order (subscription active)
POST /api/numbers/manage-subscription Stripe Billing Portal (manage/cancel subscription)
PATCH /api/numbers/[id] Update friendly label on VirtualNumber

Auto top-up (not a subscription)

  • Personal — stored on User: autoTopupEnabled, autoTopupThresholdUsd, autoTopupAmountUsd, stripeCustomerId, stripeDefaultPaymentMethodId, autoTopupInFlightPaymentIntentId.
  • Organization — stored on Organization: same auto-top-up fields + autoTopupInFlightPaymentIntentId; also stripeCustomerId + stripeDefaultPaymentMethodId when the org has completed org Checkout or org setup (see Organization wallet vs Stripe Customer).
  • Off-session charge (org): runMaybeTriggerAutoTopupOrg uses Organization.stripeCustomerId + Organization.stripeDefaultPaymentMethodId when both are set. Legacy: if the org never saved a card on the org Customer, falls back to owner’s User.stripeCustomerId + User.stripeDefaultPaymentMethodId so existing deployments keep working until the next org billing action.
  • Settings API: GET /api/user/billing/auto-topup — personal: hasSavedPaymentMethod from User.stripeDefaultPaymentMethodId, taxIdOrVat from User. Org: hasSavedPaymentMethod true if org has both Customer + PM or legacy owner/user PM paths match; taxIdOrVat prefers Organization.taxIdOrVat, then viewer’s User. PATCH /api/user/billing/auto-topup persists threshold/amount on User or Organization by context (no Stripe redirect on PATCH). $5 minimum on amount when enabling.
  • Trigger: maybeTriggerAutoTopupAfterDebit(actorUserId, walletId) in lib/auto-topup.ts after wallet debits (calls, SMS). Legacy maybeTriggerAutoTopup(userId) → personal wallet only. Call path triggers include:
    • app/api/twilio/call-status/route.ts
    • lib/call-cost-sync.ts
    • app/api/twilio/sync-calls/route.ts
  • Condition: Auto top-up enabled; threshold/amount valid (≥ $5); no inflight PI; available balance below threshold. Personal: that user’s Stripe Customer + PM. Org: org Customer + org PM, else owner User Customer + PM (legacy).
  • Charge: off-session PaymentIntent: description = Call Credits × {usd} (Auto Top-up), receipt_email = user email (personal) or owner email (org), metadata: kind = auto_topup, type: "auto_topup", credits, userId, ringvooUserId, walletCreditUsd, org scope fields when applicable (lib/auto-topup.ts). Manual wallet credit only from Checkout session webhooks (not from the Checkout PI’s payment_intent.succeeded alone — avoids double-credit).
  • Credit: payment_intent.succeeded (auto top-up PI) → creditWallet with source = stripe_auto_topup, referenceId = pi_…, fundingSource = auto_topup. Handler accepts userId in metadata if ringvooUserId were ever omitted (lib/stripe/webhook-handlers.ts).
  • Failure: payment_intent.payment_failedStripeAutoTopupFailure; inflight cleared on Organization or User depending on wallet scope.

Supporting tables

  • StripeWebhookEvent: Stripe event id (evt_…) to avoid duplicate processing alongside Transaction idempotency.
  • StripeAutoTopupFailure: failed off-session auto top-up attempts (wallet not credited).
Route Role
POST /api/billing/checkout-session Create payment Checkout Session URL; persists taxIdOrVat on User (personal) or Organization (org); uses User or org Stripe Customer per context
POST /api/billing/setup-payment-method Create setup Checkout Session URL (save card + enable auto top-up; no wallet credit); org context → org Stripe Customer (resolveRequestContext + privileged role)
GET /api/numbers/search, POST /api/numbers/create-checkout, … Virtual number search + subscription Checkout (see Virtual number purchase)
POST /api/webhooks/stripe Stripe webhooks (runtime: nodejs, raw body, signature verification)
GET /api/user/billing/auto-topup Load auto top-up settings, hasSavedPaymentMethod, taxIdOrVat
PATCH /api/user/billing/auto-topup Save auto top-up on/off and amounts (no Stripe redirect)
GET /api/user/wallet/transactions Recent Transaction rows; add ?billing=1 for TOPUP-only (billing portal credits list)
POST /api/billing/invoice Authenticated user: PDF invoice for a wallet TOPUP in the active context
GET /api/admin/payments Super-admin: list Payment rows (Stripe wallet top-ups) for /admin/billing
POST /api/admin/payments/refund Super-admin: Stripe Refund + pending status; wallet debits via refund.created / charge.refunded webhooks

API route map (local dev)

Quick links are maintained in:

  • TWILIO_VOICE_ROUTE_MAP.md

Notable endpoints:

  • /api/twilio/voice/max-duration-preview?iso=US (UI gate + duration hints; org member quota may force insufficient and zero the preview’s effective availableBalanceUsd)
  • POST /api/voice/call-start (dialer preflight — same liquidity + member quota as TwiML)
  • /api/twilio/voice/outbound
  • /api/twilio/voice/inbound
  • /api/twilio/call-status
  • Custom caller ID: /api/user/caller-ids, /api/twilio/custom-caller-id/voice, /api/twilio/custom-caller-id/confirm, /api/twilio/custom-caller-id/status, /api/admin/caller-ids
    • Primary active verification callback: /api/twilio/outgoing-caller-id/validation-status
    • Legacy custom TwiML verify routes remain in codebase but are not used by POST /api/user/caller-ids
  • /api/twilio/sync-cost-jobs?limit=50
  • /api/twilio/refresh-voice-pricing-csv (rebuild data/OutboundVoicePricing.csv; same auth pattern as sync-cost-jobs; or run npm run refresh-outbound-voice-pricing-csv locally)
  • /api/twilio/call-holds?limit=100 / /api/twilio/call-holds/cleanup?limit=500 (legacy — no CallHold table)
  • /api/billing/checkout-session (POST — Stripe payment Checkout for wallet top-up)
  • /api/billing/setup-payment-method (POST — Stripe setup Checkout; save PM for auto top-up)
  • /api/numbers/search (GET), /api/numbers/create-checkout (POST), /api/numbers/retry-provisioning (POST), /api/numbers/manage-subscription (POST), /api/numbers/[id] (PATCH) — virtual numbers
  • /api/webhooks/stripe (POST — Stripe signing secret; not NextAuth-protected)
  • /api/admin/payments (GET), /api/admin/payments/refund (POST — super-admin Stripe refunds)
  • /api/user/billing/auto-topup (GET, PATCH), /api/user/wallet/transactions (GET; optional ?billing=1), /api/billing/invoice (POST — PDF invoice)
  • SMS: /api/sms/send, /api/sms/conversations, /api/sms/logs, /api/sms/usage; Twilio webhooks /api/twilio/sms/inbound, /api/twilio/sms-status

Runtime policies and decision rules

This section is the “policy layer” you can review quickly when debugging behavior.

Call eligibility policy

Outbound PSTN calls are allowed only when all checks pass:

  1. User is authenticated in dashboard and Twilio Device is online.
  2. Destination number is present and normalized.
  3. Wallet admission: evaluateVoiceCallLiquidity()block when wallet.balance ≤ 0; otherwise allow (see Voice admission). CallSession exposure is not currently subtracted in pre-call gating (estimatedExposureUsd is 0).
  4. Org members: assertOrgMemberVoiceQuotaAllowsCall() — enabled quota with positive monthly limit and enough UTC month headroom (projected charge = per-minute estimate).
  5. TwiML-specific UX: insufficient balance / quota → spoken message + hangup.

If a check fails:

  • UI pre-block shows toast/audio (no Twilio attempt), or
  • TwiML endpoint blocks and hangs up with messaging (server-authoritative fallback).

Max duration policy (single source)

“How long a user can talk” on the PSTN leg is enforced with <Dial timeLimit=...>:

  • Implementation: computeVoiceDialTimeLimitSeconds() (lib/voice-dial-time-limit.ts) after validateVoiceCallStart().
  • Normal balance: env RINGVOO_VOICE_MAX_CALL_SECONDS (default 86400, clamped) — effectively unlimited for typical calls.
  • Low balance mode: uses computeMaxDurationDecisionFromRate() with minAllowedSeconds = 1, safetyBufferSeconds = 0, wallet budget = usableFundsUsd, then applies RINGVOO_LOW_BALANCE_TIME_LIMIT_STEPS_SECONDS.

The preview endpoint (GET /api/twilio/voice/max-duration-preview) mirrors budget seconds + low-balance stepping for UI labels; for org members it can zero out available seconds when quota blocks (see route).

Voice admission and org quota policy

  • No CallHold rows — admission is wallet.balance > 0 (plus org member quota for MEMBER). CallSession exists for settlement/idempotency and estimated pricing gaps, not for subtracting concurrent “reserved minutes” during pre-call checks in the current codepath.
  • Settlement does not “release holds”; wallet changes only via Transaction + CallSettlement reconciliation.
  • Provisional estimated debit when Twilio price is missing on completed; cost sync jobs and later callbacks finalize pricing.

Settlement and retry policy

Primary settlement:

  • Terminal call-statusupsertCallAndSettleWallet (with advisory lock). If completed and Twilio price present → retail debit + CallSettlement. If price missing → estimated debit + settled_estimated, then delta when price arrives.

Fallback settlement:

  • If completed call has no Twilio price yet, enqueue CallCostSyncJob.
  • Retry attempts with backoff; stop as completed (priced) or failed (exhausted).
  • Normal path:
    • terminal Twilio callback reaches app
    • if price is missing, queue delayed sync by child billable SID
  • Recovery path:
    • provisional parent-only call is recovered to a child SID later
    • if recovered child already has price, settle immediately in the same sync-cost run
    • if recovered child still has no price, enqueue normal delayed sync-cost retry

Key principle: do not mark sync job complete before call row is visible and settlement can occur.

Refund policy

A) Voice call goodwill wallet credits (applySystemRefundIfEligible in lib/wallet.tsTransaction.source = "refund", positive TOPUP credit)

Auto-credited when either:

  • terminal failed-like statuses: failed, busy, no-answer, canceled
  • short-call goodwill path (under threshold, default 5s)

Exception:

  • do not apply short-call goodwill refund for completed calls when Twilio carrier cost was positive.

Idempotent via Transaction(referenceId=callSid, source="refund").

B) Stripe card refunds (money back to the customer’s bank card)

  • Implemented for super-admin–initiated refunds: POST /api/admin/payments/refund creates the Stripe Refund; refund.created / charge.refunded webhooks debit the wallet idempotently (Stripe card refunds (super-admin API + webhooks)). Refunds created only in the Stripe Dashboard still emit webhooks — ensure POST /api/webhooks/stripe is healthy so the ledger stays aligned.

Environment variables (source of truth)

Primary list: .env.example

This section documents meaning + where it’s used (file pointers).

Feature flags

  • ENABLE_NUMBERS_PAGE
    • Server-side flag to keep Numbers implementation in code while hiding user access.
    • Used by: lib/feature-flags.ts, app/dashboard/numbers/page.tsx, app/dashboard/numbers/success/page.tsx, lib/stripe/virtual-number-checkout.ts.
    • Behavior:
      • false → hide Numbers entry points + redirect Numbers routes to /dashboard/phone
      • unset/any non-false value → Numbers page enabled
  • NEXT_PUBLIC_ENABLE_NUMBERS_PAGE
    • Client mirror exposed via next.config.js from ENABLE_NUMBERS_PAGE.
    • Used by client navigation/components (Sidebar/UserMenu/SMS CTA/Settings link routing).
    • Typically not set manually; generated from server flag.

Twilio

  • TWILIO_ACCOUNT_SID
    • Used by: Twilio REST client + Pricing API (lib/twilio.ts, lib/billing/twilio-voice-pricing.ts)
    • Missing → token creation / REST / pricing throws
  • TWILIO_AUTH_TOKEN
    • Used by: webhook validation + REST + Pricing API (lib/twilio.ts, lib/billing/twilio-voice-pricing.ts)
    • Missing → webhook validation fails closed (returns false)
  • TWILIO_API_KEY, TWILIO_API_SECRET
    • Used by: Twilio Voice SDK access token generation (lib/twilio.ts)
  • TWILIO_TWIML_APP_SID
    • Used by: VoiceGrant outgoingApplicationSid (lib/twilio.ts)
  • TWILIO_PHONE_NUMBER
    • Used by: outbound callerId and as originationPhone for pricing decisions (app/api/twilio/voice/outbound/route.ts)
  • ENABLE_TWILIO_TEST_MODE
    • When true: Twilio virtual-number provisioning/search uses test credentials (lib/twilio/virtual-number-client.ts); searchAvailableNumbers returns fake inventory with local filter simulation (lib/twilio/virtual-number-service.ts). Requires TWILIO_TEST_ACCOUNT_SID / TWILIO_TEST_AUTH_TOKEN (and related test keys as implemented there).
    • In this mode, successful virtual-number provisioning stores simulated SIDs like PNTEST... by design (not live Twilio IncomingPhoneNumber resources).
    • When unset/false: live Twilio Available Phone Numbers API for search and purchase.

Server URL + webhooks

  • SERVER_URL
    • Used to construct absolute callback URLs for Twilio:
      • status callback: /api/twilio/call-status
      • recording status callback: /api/twilio/recording-status
    • Used by: lib/twilio.ts for webhook validation expected URL (prefers SERVER_URL).

Billing / wallet

  • RINGVOO_VOICE_RETAIL_MULTIPLIER
    • Used by: lib/billing/voice-pricing.ts
    • Default: 2
  • RINGVOO_LOW_BALANCE_THRESHOLD_PERCENT (optional)
    • Used by: lib/billing/voice-wallet-credit.ts (isVoiceLowBalanceMode)
    • Default: 0.02
    • Meaning: when Wallet.lastPurchaseAmount > 0, low balance mode triggers when wallet.balance ≤ lastPurchaseAmount × threshold.
  • RINGVOO_LOW_BALANCE_TIME_LIMIT_STEPS_SECONDS (optional)
    • Used by: lib/voice-dial-time-limit.ts, app/api/twilio/voice/max-duration-preview/route.ts
    • Default: 60,120,180 (CSV of positive integers; de-duped + sorted)
    • Meaning: in low balance mode, TwiML <Dial timeLimit> becomes the largest step budget-based seconds (see Worked low-balance time limit stepping); if budget is below the smallest step, the code still picks the smallest step.
  • RINGVOO_VOICE_MAX_CALL_SECONDS (optional)
    • Max <Dial timeLimit> cap in seconds (parsed in lib/voice-dial-time-limit.ts, clamped to at least 60 and at most 604800). When unset, defaults to 86400 (24h). In normal balance mode, TwiML uses this cap; in low balance mode, the effective limit is the minimum of this cap and the chosen step seconds.
  • RINGVOO_INBOUND_LOW_BALANCE_CALLER_MESSAGE (optional)
    • Used by: app/api/twilio/voice/inbound/route.ts (TwiML <Say> fallback)
    • Default: polite “unavailable” script (see route)
  • RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_URL (optional)
    • Used by: inbound TwiML <Play> when set to an http(s) URL
  • RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_PATH (optional)
    • Used with SERVER_URL to build <Play> audio as ${SERVER_URL}${path} when no absolute override is set
    • Default path if unset: /sounds/ringvoo-inbound-caller-unavailable.wav
  • RINGVOO_INBOUND_VOICE_PRICE_SOURCE (optional)
    • csv (default when unset or any value other than twilio): inbound completed settlement uses CSV longest-prefix (or country floor) and does not use Twilio’s browser-leg Price for retail; aligns with table pricing (lib/call-billing.ts, isInboundVoiceBillingFromTwilioWebhook).
    • twilio: inbound settlement uses Twilio webhook/REST carrier Price × RINGVOO_VOICE_RETAIL_MULTIPLIER when present (similar to outbound).
  • TWILIO_LOG_BILLING (optional)
    • When 1 / true: verbose [CallBilling] logs in lib/call-billing.ts (duration source selection, etc.).
  • SIGNUP_BONUS_USD_PROD, SIGNUP_BONUS_USD_DEV
    • Used by: lib/wallet.ts (signup bonus)
    • Defaults if missing/invalid: prod $0.25, dev $50

SMS billing

SMS uses per-segment retail rates in SmsCountryPricing, not RINGVOO_VOICE_RETAIL_MULTIPLIER. Full formulas, import, and flows: SMS (Twilio Messaging).

  • SMS_OUTBOUND_MARKUP_MULTIPLIER, SMS_INBOUND_MARKUP_MULTIPLIER — markup at CSV import (lib/billing/sms-config.ts, lib/billing/sms-pricing-import.ts).
  • SMS_OUTBOUND_MINIMUM_FLOOR_USD, SMS_INBOUND_MINIMUM_FLOOR_USD — per-segment floors at import.
  • SMS_BILLING_CURRENCY — stored on pricing rows (default USD).
  • SMS_ALLOW_NEGATIVE_BALANCE, SMS_NEGATIVE_BALANCE_LIMIT — inbound wallet debit policy (lib/wallet.ts debitWalletInboundSms).
  • Optional Twilio SMS logs: TWILIO_SMS_LOG_INBOUND, TWILIO_SMS_LOG_OUTBOUND, TWILIO_SMS_LOG_STATUS (lib/twilio-logging.ts).

Auth / NextAuth

  • NEXTAUTH_URL
    • Used by: NextAuth, and by email links in lib/email.ts (base URL).
  • NEXTAUTH_SECRET
    • Used by: NextAuth and middleware (lib/auth.ts, middleware.ts)
  • GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
    • Optional, used by: lib/auth.ts to enable Google OAuth provider.

Email (Resend)

  • RESEND_API_KEY
    • Used by: lib/email.ts
    • If missing: emails are disabled (warns).
  • TRANSACTIONAL_FROM_EMAIL, TRANSACTIONAL_REPLY_TO_EMAIL
    • Used by: verification + password-reset emails in lib/email.ts.
  • SUPPORT_FROM_EMAIL, SUPPORT_INBOX_EMAIL
    • Used by: support/contact submissions (sendSupportEmail in lib/email.ts, POST /api/support).
    • SUPPORT_INBOX_EMAIL is also used on PDF invoices from /dashboard/billing (app/api/billing/invoice/route.tslib/billing/invoice-pdf.ts; falls back to support@ringvoo.com if unset).
    • Current format: intentionally simple/plain internal email (not the styled alert shell).
  • ALERT_FROM_EMAIL, ALERT_INBOX_EMAIL
    • Used by: inbound SMS and missed-call user notifications in lib/email.ts.
    • Current intended behavior: alerts send from info@ringvoo.com, and user replies go to info@ringvoo.com.
  • Manual top-up reminder email (super-admin action):
    • Triggered by: POST /api/admin/users/[id]/topup-reminder and admin UI actions (/admin/users, /admin/sms-risk).
    • Uses transactional sender/reply-to settings from lib/email.ts (sendAdminTopupReminderEmail).

Current alert behavior

  • Inbound SMS: when the server receives a valid inbound SMS (live webhook or later sync-sms recovery import), the owning user gets an email alert.
  • Missed calls: when the server receives a terminal missed-call status (busy, failed, no-answer, canceled) for a user-owned inbound call, the owning user gets a missed-call email alert.

Cron / admin secrets

  • CRON_SECRET
    • Used by:
      • /api/twilio/sync-cost-jobs authorization (app/api/twilio/sync-cost-jobs/route.ts)
      • /api/twilio/refresh-voice-pricing-csv authorization (app/api/twilio/refresh-voice-pricing-csv/route.ts)
      • /api/twilio/call-holds/cleanup authorization (app/api/twilio/call-holds/cleanup/route.ts)
      • /api/twilio/sync-calls authorization (app/api/twilio/sync-calls/route.ts)
      • /api/twilio/sync-sms authorization (app/api/twilio/sync-sms/route.ts)
    • Bearer cron routes sync-cost-jobs and refresh-voice-pricing-csv: under next dev (NODE_ENV=development), bearer is optional even when CRON_SECRET is set (lib/cron-bearer-auth.ts). In production (next start / Vercel), bearer must match when CRON_SECRET is set.
  • TWILIO_SYNC_CALLS_SECRET (optional)
    • Used by: /api/twilio/sync-calls as an additional bearer auth option.
  • TWILIO_SYNC_SMS_SECRET (optional)
    • Used by: /api/twilio/sync-sms as an additional bearer auth option.

Logging

  • RINGVOO_PERF_LOGS (optional)
    • When true: server-side [Perf]... timing logs on selected dashboard pages and APIs (e.g. dashboard/phone, dashboard/buy-credits, resolveRequestContext, max-duration-preview).
    • Default: off. Use for local or staging profiling; not required in production.
  • TWILIO_SERVER_LOGS
    • Global enable/disable for Twilio non-error logs.
    • Used by: lib/twilio-logging.ts
  • TWILIO_SERVER_LOG_SCOPES
    • Comma-separated: voice,billing,recording,rest
    • Used by: lib/twilio-logging.ts
  • PRISMA_QUERY_LOGS
    • Enable query logs (boolean).
    • Used by: lib/db.ts
  • PRISMA_LOG_LEVELS
    • Comma-separated levels: query,info,warn,error
    • Used by: lib/db.ts
  • TWILIO_LOG_CUSTOM_CALLER_ID_COST
    • Optional verbose logs for custom caller-ID verification calls (status callback + fetched Twilio price/duration).
    • Used by: app/api/user/caller-ids/route.ts, app/api/twilio/custom-caller-id/status/route.ts

Client-only UX knobs

  • NEXT_PUBLIC_VOICE_BACK_TO_BACK_HINT_WINDOW_MS
    • Used by: components/marketing/DialerPreview.tsx
    • Purpose: back-to-back dial debounce window while settlement/pricing may be catching up (pairs with the finalizing toast/audio UX).
    • Default 4000 (min 500, max 15000)
  • NEXT_PUBLIC_DIALER_RINGBACK_AUDIO_SRC (optional)
    • Used by: lib/dialer-call-progress-audio.ts
    • Default: /sounds/ringvoo-ringback-cc0.mp3
  • NEXT_PUBLIC_DIALER_CONNECT_MESSAGE_AUDIO_SRC (optional)
    • Used by: lib/dialer-call-progress-audio.ts
    • Default: /sounds/ringvoo-connect-message.wav
  • NEXT_PUBLIC_DIALER_CONNECT_PROMPT_TEXT (optional)
    • Used by: lib/dialer-call-progress-audio.ts (Web Speech fallback text)
  • NEXT_PUBLIC_DIALER_HANGUP_TONE_AUDIO_SRC (optional)
    • Used by: lib/dialer-call-progress-audio.ts
    • Default: /sounds/ringvoo-end-call.mp3
  • NEXT_PUBLIC_INCOMING_RINGTONE_AUDIO_SRC (optional)
    • Used by: lib/incoming-ringtone.ts
    • Default: /sounds/ringvoo-incoming.mp3
  • NEXT_PUBLIC_DIALER_LOW_BALANCE_BLOCK_AUDIO_SRC / NEXT_PUBLIC_DIALER_LOW_BALANCE_MEMBER_BLOCK_AUDIO_SRC (optional)
    • Used by: components/marketing/DialerPreview.tsx
    • Defaults: /sounds/ringvoo-dialer-low-balance-user.wav, /sounds/ringvoo-dialer-low-balance-member.wav
  • NEXT_PUBLIC_DIALER_LOW_BALANCE_BLOCK_MESSAGE / NEXT_PUBLIC_DIALER_LOW_BALANCE_MEMBER_BLOCK_MESSAGE (optional)
    • Used by: components/marketing/DialerPreview.tsx (toast copy)
  • NEXT_PUBLIC_DIALER_FINALIZING_AUDIO_SRC / NEXT_PUBLIC_DIALER_FINALIZING_MESSAGE (optional)
    • Used by: components/marketing/DialerPreview.tsx
    • Defaults: /sounds/ringvoo-dialer-finalizing.wav, “Finalizing previous call... Starting your next call shortly.”
  • NEXT_PUBLIC_DIALER_CALL_NOT_COMPLETED_AUDIO_SRC (optional)
    • Used by: components/marketing/DialerPreview.tsx
    • Default: /sounds/ringvoo-call-not-completed.wav
  • NEXT_PUBLIC_DEMO_VIDEO_URL / NEXT_PUBLIC_DEMO_VIDEO_SRC
    • Used by: components/marketing/VideoSection.tsx
    • Purpose: configure marketing demo video source.
  • NEXT_PUBLIC_ENABLE_NUMBERS_PAGE
    • Client-safe mirror for Numbers-page visibility; used to hide/show Numbers links without deleting code.

Database / Prisma

  • DATABASE_URL
    • Required by Prisma datasource (prisma/schema.prisma)
  • DIRECT_DATABASE_URL
    • Present in .env.example for migration workflows (not referenced directly in Prisma schema here).
  • Deploy: after pulling migrations (e.g. 20260411210000_organization_stripe_pm_tax for org PM + tax fields; 20260411230000_drop_team_team_member to drop legacy Team / TeamMember; 20260414120000_voice_call_session_settlement for CallSession / CallSettlement / Wallet.lastPurchaseAmount and drops CallHold), run npx prisma migrate deploy in each environment. On Windows, npx prisma generate can hit EPERM if npm run dev locks the Prisma engine — stop the dev server, then generate.

Stripe

Used by: lib/stripe/client.ts, lib/stripe/checkout-session.ts, lib/stripe/org-customer.ts, lib/stripe/virtual-number-checkout.ts, app/api/webhooks/stripe/route.ts.

  • STRIPE_SECRET_KEY
    • Required for Checkout Session creation and webhook verification path that uses the Stripe SDK.
    • Missing → checkout session creation throws.
  • STRIPE_WEBHOOK_SECRET
    • Signing secret from Stripe Dashboard (webhook endpoint) or from stripe listen when testing locally.
    • Missing → /api/webhooks/stripe returns 500 (misconfigured).
  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (optional)
    • Publishable key (pk_…); not required for redirect-only Checkout but useful for future Elements or client-side checks.
  • STRIPE_CHECKOUT_ADAPTIVE_PRICING (optional)
    • When true / 1 / yes: enable Stripe Adaptive Pricing on Checkout (adaptive_pricing.enabled).
    • Otherwise (unset, false, etc.): offlib/stripe/checkout-adaptive-pricing.ts passes enabled: false on wallet, virtual-number subscription, and setup Checkout sessions.
  • STRIPE_FIRST_PURCHASE_REWARD_COUPON_ID (optional but required for the first-purchase reward feature)
    • Stripe Coupon id (coupon_…) used as the template when the app creates a per-user Promotion Code after the first manual wallet top-up (lib/billing/user-coupon.ts). Not a customer-facing marketing code.
  • STRIPE_VIRTUAL_NUMBER_PRICE_ID
    • Recurring Price id (price_…) used as the pricing template for virtual number subscription Checkout. The app prices.retrieve this id and builds inline price_data + product_data so the Hosted Checkout line item shows the selected phone number; each session still creates new Stripe Product/Price rows in the catalog (see Virtual number purchase). Required when that flow is enabled; separate from wallet top-up.

Marketing and campaign codes are usually created in the Stripe Dashboard (Coupon + Promotion Code); see Promotion codes — wallet top-up. Virtual number Checkout does not expose allow_promotion_codes.

See also: Stripe wallet funding, Virtual number purchase.

Operational notes & troubleshooting

.next artifacts and git noise

The repo already ignores .next/ in .gitignore. If you see .next/... untracked, it usually means:

  • you ran the build before .gitignore existed, or
  • your git status snapshot is old, or
  • there are nested .gitignore rules (unlikely here).

“Insufficient balance” vs back-to-back settlement / legacy PENDING_HOLD_SETTLEMENT

  • Insufficient: evaluateVoiceCallLiquidity fails because wallet.balance ≤ 0 (see Voice admission). Separately, GET /api/twilio/voice/max-duration-preview may return insufficient: true when org member quota blocks (it zeros the preview’s effective balance) or when other gates fail. Org members can also be blocked by quota even when the wallet has funds.
  • Back-to-back UX: the dialer may show a toast + short “finalizing” audio while Twilio/webhooks finalize call-status / CallSettlement / wallet debits; this is not a CallHold race anymore.
  • PENDING_HOLD_SETTLEMENT: legacy handler branch in TwiML routes still hangs up silently if that exact error string appears (avoids TTS billing on a tiny prompt). With CallHold removed, new code paths should not emit it; treat as rare / legacy.

Pricing fetch failures

If Twilio Pricing API returns no outbound pricing rows for a country, we fail closed (no unsafe time limit). This can make outbound calls “unavailable” for that ISO until pricing is available.

Missing inbound SMS: app vs carrier

  • If a new inbound SMS does not appear in Ringvoo and does not appear in Twilio Message logs, the problem is upstream of Ringvoo (carrier / route / Twilio delivery path), not the webhook processor.
  • sync-sms can only recover messages that Twilio actually received and retained in Twilio message history.
  • If the message exists in Twilio logs but not in Ringvoo, then check webhook delivery, SERVER_URL, Twilio signature validation, and /api/twilio/sync-sms.

Stripe webhooks

If wallet top-ups never credit:

  • Confirm POST /api/webhooks/stripe is reachable from the internet (e.g. ngrok HTTPS URL) and STRIPE_WEBHOOK_SECRET matches the signing secret for that endpoint in the Stripe Dashboard.
  • In Stripe → Developers → Webhooks → your endpoint, Recent deliveries should show 200. 400 on signature usually means wrong secret or body was parsed as JSON (this route must receive the raw body).
  • Required events include at least: checkout.session.completed, checkout.session.async_payment_succeeded, payment_intent.succeeded, payment_intent.payment_failed, refund.created, charge.refunded (wallet debit for card refunds), plus subscription/invoice events used by virtual numbers (invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, … — see lib/stripe/webhook-handlers.ts). checkout.session.completed also drives mode: setup (save-card-only) sessions — no wallet row is created for those.
  • NEXTAUTH_URL / app base URL used in Checkout success_url / cancel_url should match how users access the app (lib/stripe/client.ts getAppBaseUrl() prefers NEXTAUTH_URL then SERVER_URL).

Delayed Twilio price

If completed calls are not being billed:

  • confirm call-status is receiving callbacks (requires SERVER_URL)
  • check CallCostSyncJob rows; run /api/twilio/sync-cost-jobs?limit=50
  • confirm TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are set (REST fetch requires them)

Important nuance:

  • not every eventually-settled recovery call will create a CallCostSyncJob row
  • if sync-cost recovery discovers the child SID and that child already has price, settlement may happen immediately in the same sync-cost run
  • in that case you may see recoveredParents > 0 with scanned = 0 and no new job row, yet the call / CallSettlement / wallet still settle correctly

Dialer UX behavior reference

Source files for this section:

  • components/marketing/DialerPreview.tsx
  • components/dashboard/TwilioProvider.tsx
  • components/marketing/Toast.tsx
  • lib/dialer-call-progress-audio.ts (outbound local ringback + connect line + hangup tone)
  • lib/incoming-ringtone.ts (inbound ringtone URL + unlock)

These are the actual modal overlays in DialerPreview today:

  • Call ended due to max duration modal
    • state: endedForBalanceModalOpen
    • trigger: call disconnects near planned safeMaxSeconds, not user hangup
    • behavior: explain max-time cap, CTA to buy credits
  • Add contact modal
    • state: contactModalOpen
    • behavior: local UI helper, fills number into dialer
  • Custom caller ID modal
    • state: customCallerModalOpen
    • behavior: starts backend verification call flow via /api/user/caller-ids; user enters Twilio-spoken code and verification status is reflected from API state (verified, cooldown/status fields)
  • Buy phone number modal
    • state: buyNumberModalOpen
    • behavior: currently UI preview flow for future number-purchase backend
  • Incoming call popup / welcome audio popup
    • owned by Twilio provider; handles inbound ring UX and browser audio unlock

Non-modal call blocking UX (toast + audio)

Several “hard blocks” are not modal dialogs anymore — they’re toast + optional audio cues:

  • Insufficient / gated dial: /api/twilio/voice/max-duration-preview returns insufficient: true (wallet ≤ 0, org member quota gate, etc.). UI shows a toast and does not start Twilio dialing.
  • Low balance block (marketing dialer): separate toast copy + WAV for user vs org member (NEXT_PUBLIC_DIALER_LOW_BALANCE_*).
  • Back-to-back settlement / pricing catch-up: internal finalizingModalOpen state still exists, but UX is toast + finalizing WAV (not a blocking modal). Wallet display may be briefly masked while polling catches up.
  • Finalizing dial wait cap: NEXT_PUBLIC_DIALER_FINALIZING_WAIT_CAP_MS bounds how long a single dial attempt waits in finalizing before prompting the user to retry (Still finalizing. Tap Dial to continue.). This is UX-only and does not bypass balance/quota admission rules.

Toast message matrix

Toast system:

  • event bus: window.dispatchEvent("ringvoo-toast")
  • component: components/marketing/Toast.tsx
  • display duration: ~3400ms
  • types: success s, error e, info i

Current high-signal toasts include:

  • Enter a number to call
  • Go online first to make a call
  • Finalizing previous call... Starting your next call shortly. (configurable via NEXT_PUBLIC_DIALER_FINALIZING_MESSAGE)
  • Still finalizing. Tap Dial to continue. (shown when finalizing wait cap is reached)
  • Twilio is not ready. Try going online again.
  • Microphone permission is required to call.
  • Call failed to start. Please try again.
  • Android/audio routing hints during accepted calls
  • Twilio provider lifecycle toasts (You're live! Ready to call., reconnect hints, errors)

Call overlay and in-call controls

During call, overlay provides:

  • remote number display
  • call status text
  • running timer
  • mute toggle
  • speaker toggle (best-effort; browser/device limitations apply)
  • hangup action

Policy notes:

  • if user explicitly presses hangup, “call ended for balance” modal should not appear.
  • disconnect/reject/error shortly after dialing may trigger back-to-back info toast instead of generic failure.
  • accepted/disconnected calls also persist client fallback timing so missing Twilio callback cases can still populate provisional Call rows, show history, and later recover for settlement.

Local dialer & browser audio files

Dialer and gate env toggles (recent)

  • NEXT_PUBLIC_DIALER_FINALIZING_WAIT_CAP_MS
    • purpose: cap dial-time finalizing wait before prompting retry
    • default in code: 8000 ms (range 2000..15000)
    • current project value: 6000 in .env and .env.example
  • RINGVOO_GATE_RECONCILE_ENABLED
    • purpose: optional gate-path reconcile in max-duration-preview
    • default behavior: disabled unless explicitly set to true
    • note: when enabled, reconcile is time-bounded and outside core gate transaction
  • RINGVOO_PERF_LOGS / NEXT_PUBLIC_RINGVOO_PERF_LOGS
    • purpose: server/client dial performance instrumentation
    • operational note: useful for troubleshooting latency, not required for runtime logic

Defaults live under public/sounds/ (served from /sounds/...). Production/staging typically overrides the URLs via NEXT_PUBLIC_*_AUDIO_SRC so CDN/hosting can change without code edits.

File (default) Role
ringvoo-ringback-cc0.mp3 CC0 European-style ringback: timed intro bursts + loop while the outbound call is ringing. See ringvoo-ringback-cc0.txt for source/attribution. (NEXT_PUBLIC_DIALER_RINGBACK_AUDIO_SRC)
ringvoo-connect-message.wav Spoken “please wait …” line after the intro rings (NEXT_PUBLIC_DIALER_CONNECT_MESSAGE_AUDIO_SRC).
ringvoo-end-call.mp3 Hangup sound when the user taps the red end button (NEXT_PUBLIC_DIALER_HANGUP_TONE_AUDIO_SRC).
ringvoo-incoming.mp3 Inbound-call ringtone (NEXT_PUBLIC_INCOMING_RINGTONE_AUDIO_SRC).
ringvoo-dialer-low-balance-user.wav Marketing dialer: low-balance block cue for non-member contexts (NEXT_PUBLIC_DIALER_LOW_BALANCE_BLOCK_AUDIO_SRC).
ringvoo-dialer-low-balance-member.wav Marketing dialer: low-balance block cue for org member contexts (NEXT_PUBLIC_DIALER_LOW_BALANCE_MEMBER_BLOCK_AUDIO_SRC).
ringvoo-dialer-finalizing.wav Marketing dialer: short “settling wallet/pricing” cue for back-to-back dials (NEXT_PUBLIC_DIALER_FINALIZING_AUDIO_SRC).
ringvoo-call-not-completed.wav Marketing dialer: generic early-failure cue when the attempt ends before connect (NEXT_PUBLIC_DIALER_CALL_NOT_COMPLETED_AUDIO_SRC).
ringvoo-inbound-caller-unavailable.wav Default hosted audio for inbound “callee wallet empty” TwiML <Play> (RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_PATH).

Outbound flow (lib/dialer-call-progress-audio.ts, used from DialerPreview):

  1. unlockDialerCallProgressAudio() + startDialerCallProgressAudio() run on Dial in the same user gesture and before await getUserMedia, so the browser does not block play() / speech later.
  2. Sequence: two ringback bursts → short gap → connect message (WAV; Web Speech fallback using NEXT_PUBLIC_DIALER_CONNECT_PROMPT_TEXT) → looped ringback until the call is answered or ends.
  3. On Twilio accept: stopDialerCallProgressAudio() with no options — all local progress audio stops immediately so it does not overlap the live call (no double “female line + callee audio”).
  4. On disconnect / reject / error from the SDK: stop progress audio; hangup tone only when appropriate (e.g. not on a simple connect-stop path).

Regenerating bundled voice WAVs from .env strings (Windows):

  • Script: scripts/regenerate-voice-prompts.ps1
  • Command: npm run regen-voice-prompts

All related changes (dialer audio UX, cumulative):

  • Removed unused legacy assets (e.g. old connect chime / extra ring samples); outbound UX is ringback + optional connect line + loop, then silence after connect.
  • Dropped separate “connect tone” before rings; no overlapping local speech after Twilio reports connected.
  • End-call feedback standardized on ringvoo-end-call.mp3 (renamed from earlier android_end_call_tone.mp3 for consistent naming).
  • Connect message is WAV-first in codepaths (MP3-first connect line is no longer assumed); Web Speech remains as a last-resort fallback when decode/play fails.
  • Hangup clip played through Web Audio with configurable gain (louder than raw <audio volume=1> when the buffer is preloaded).

Go online / reconnect behavior

Twilio provider keeps intent and actual registration state separate:

  • wantsOnlineRef tracks user intent.
  • unregistered/error events can auto-retry a few times before requiring manual recovery.
  • offline choice is persisted to local storage.
  • cross-tab active tab key avoids multiple tabs fighting for call handling.

Practical rule:

  • if a user says “online button looks on but calls fail”, verify device registration events and token fetch path in TwilioProvider.

Virtual phone numbers (dedicated section)

This section isolates the virtual number feature for faster localhost and production checks.

Scope

  • Twilio number search and purchase flow for US / CA
  • Stripe subscription checkout for number ownership
  • Provisioning into VirtualNumber + TwilioNumber
  • Localhost test mode versus real Twilio provisioning

Core flow

  1. User opens Numbers page or buy-number modal.
  2. App calls GET /api/numbers/search.
  3. User selects a number and starts checkout (POST /api/numbers/create-checkout).
  4. Stripe subscription checkout completes.
  5. Webhook provisions Twilio incoming number and writes DB rows:
    • TwilioNumber
    • VirtualNumber
    • VirtualNumberOrder status/attempt rows
  6. Number appears in dashboard selection (phone call-from mode).

Subscription billing (quick reference)

  • Failed monthly charge → Stripe webhooks → VirtualNumber.status = past_due; Settings shows a payment warning; app still treats past_due like active for number use while Stripe retries (see Virtual number subscription billing).
  • Stripe cancels the subscription → customer.subscription.deleted → Twilio release + DB delete (VirtualNumber + TwilioNumber). Details: same linked section.

Main files

  • components/dashboard/numbers/NumbersPageClient.tsx
  • app/dashboard/numbers/page.tsx
  • app/api/numbers/search/route.ts
  • app/api/numbers/create-checkout/route.ts
  • app/api/numbers/retry-provisioning/route.ts
  • lib/twilio/virtual-number-service.ts
  • lib/stripe/virtual-number-checkout.ts
  • lib/stripe/virtual-number-webhook.ts

Localhost test mode vs real mode

  • Test mode (ENABLE_TWILIO_TEST_MODE=true):
    • deterministic local simulation for search/purchase outcomes
    • successful simulated rows store VirtualNumber.twilioSid like PNTEST...
    • valid for UI/retry/provisioning flow checks, not proof of live Twilio ownership
  • Real mode (ENABLE_TWILIO_TEST_MODE=false):
    • uses live Twilio search and incoming-number provisioning
    • VirtualNumber.twilioSid should be real PN... (not PNTEST...)
    • caller-id presentation tests are meaningful in this mode

Fast validation checklist

  • .env has ENABLE_TWILIO_TEST_MODE=false for live behavior tests
  • TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN match the account owning the number
  • VirtualNumber.phoneNumber and TwilioNumber.phoneNumber are E.164 (+1...)
  • VirtualNumber.twilioSid is real PN...
  • VirtualNumber.status is active (or eligible status per policy)

Caller-id behavior (virtual number)

  • When user selects an owned virtual number (phone mode), the system sets it as <Dial callerId>.
  • Twilio must authorize that number on the same account (owned incoming number).
  • If the callee sees the selected number, end-to-end path is confirmed.

Useful DB checks

SELECT id, "phoneNumber", "twilioSid", "twilioAccountSid", status
FROM "VirtualNumber"
ORDER BY "createdAt" DESC;
SELECT id, "userId", "phoneNumber"
FROM "TwilioNumber"
ORDER BY "createdAt" DESC;
SELECT id, "selectedNumber", "provisioningStatus", "createdAt"
FROM "VirtualNumberOrder"
ORDER BY "createdAt" DESC;
SELECT id, "virtualNumberOrderId", "attemptedNumber", outcome, "twilioErrorCode", "errorMessage", "createdAt"
FROM "VirtualNumberProvisionAttempt"
ORDER BY "createdAt" DESC;

Troubleshooting signals

  • Dropdown shows number but call behavior fails:
    • row may be test-provisioned (PNTEST...) or account/SID mismatch.
  • Works in UI but not in live caller-id:
    • verify real Twilio ownership and matching TWILIO_ACCOUNT_SID.
  • Retry provisioning loop:
    • inspect latest VirtualNumberProvisionAttempt for Twilio code/message.

Future roadmap anchors

When adding new features, update this doc in these places:

  • SMS — keep SMS (Twilio Messaging), SMS environment variables, and .env.example aligned with lib/billing/sms-config.ts, lib/sms-send.ts, lib/sms-inbound.ts, and pricing import.
  • Stripe — keep Stripe wallet funding (including Organization wallet vs Stripe Customer, Hosted Checkout, Stripe customer emails, Stripe card refunds, Active billing context) and Environment variables in sync with code (see lib/stripe/, lib/stripe/org-customer.ts, app/api/billing/, app/api/webhooks/stripe/, app/api/admin/payments/).
  • Prisma — new billing columns on Organization require a migration + prisma migrate deploy in each environment; document under Data model (Prisma).
  • Buy number
    • Extend TwilioNumber ownership rules and inbound routing assumptions.
    • Document lifecycle: purchase → assign to user/team → configure voice webhook URL.
  • Enterprise admin panel
    • Super-admin-only routes under /admin/** (e.g. /admin/billing, POST /api/admin/payments/refund) — document RBAC (UserRole.SUPER_ADMIN), Payment / Transaction expectations, and that Stripe refunds must go through webhooks for wallet alignment.
    • Broader notes on RBAC (UserRole) and multi-tenant constraints (ContextScope, org wallet, OrgMemberRole).
  • Stripe org lifecycle (optional later) — deleting an Organization in-app does not delete the Stripe Customer; add Dashboard cleanup or customers.del if you need strict GDPR/provider hygiene.