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
- Current capabilities
- High-level architecture
- Data model (Prisma)
- Multi-tenant workspace (Personal vs Organization)
- Twilio Voice flows
- Custom caller ID verification
- SMS (Twilio Messaging)
- SMS data model
- Segment counting
- Country pricing rows (
SmsCountryPricing) - Retail price per segment (formula)
- Pricing import (
SMSPricing.csv) - Outbound SMS: cost calculation and flow
- Inbound SMS: cost calculation and flow
- Wallet debits (SMS)
- Twilio status callbacks (outbound)
- Dashboard routes and product rules
- SMS environment variables
- Pricing and billing rules
- Retail multiplier
- How we fetch carrier pricing (Twilio Pricing API)
- Why max duration uses the highest rate
- Max duration math (safeMaxSeconds)
- Worked max-duration example
- Voice admission, low balance mode,
CallSession, and policy knobs - Worked low-balance time limit stepping
- Wallet debit + refunds
- Idempotency & consistency model
- Stripe wallet funding
- Organization wallet vs Stripe Customer (important)
- Active billing context (cookie and API headers)
- Hosted Checkout (wallet) — line item, branding, tax
- Stripe customer emails, receipt copy, and test vs live
- Promotion codes — wallet top-up
- Billing portal: credits and invoices
- Stripe card refunds (super-admin API + webhooks)
- Virtual number purchase (Stripe subscription + Twilio)
- Virtual phone numbers (dedicated section)
- API route map (local dev)
- Runtime policies and decision rules
- Environment variables (source of truth)
- Operational notes & troubleshooting
- Dialer UX behavior reference
- Future roadmap anchors
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
CallHoldrows: new calls are blocked whenWallet.balance ≤ 0; whenWallet.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).CallSessionrows still exist for settlement/idempotency, but pre-call liquidity no longer subtracts concurrent-session exposure (exposure is treated as 0 inevaluateVoiceCallLiquidity). 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
CallHoldrows: no credit extension (computeVoiceCreditLimitUsdis 0);lastPurchaseAmountis still tracked for low balance mode thresholds (RINGVOO_LOW_BALANCE_THRESHOLD_PERCENT);CallSessionsupports settlement; org members also have a monthly voice quota (OrgMemberVoiceQuota/OrgMemberVoiceQuotaUsage). - Call status callback handler that persists calls and settles wallet debits/refunds;
CallSettlementfor idempotent audit; provisional estimated debit when Twiliopriceis missing oncompleted. - 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 useUser.stripeCustomerId; organization wallet top-ups and org setup sessions useOrganization.stripeCustomerId(dedicated org payer in Stripe — see Organization wallet vs Stripe Customer). Tax ID:User.taxIdOrVat(personal) orOrganization.taxIdOrVat(org). Auto top-up: personal uses User PMs; org prefersOrganization.stripeDefaultPaymentMethodIdon 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 (canDownloadWorkspaceInvoiceinlib/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
TwilioNumbernumbers only (UI copy: "From (purchased Ringvoo number)"); inbound via Twilio webhook; per-segment retail billing from importedSmsCountryPricing; 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.comreply handling.
Inbound voice billing: Inbound calls are wallet-gated in TwiML (validateVoiceCallStart + <Dial timeLimit=…>). Post-call settlement (call-status → upsertCallAndSettleWallet) 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/supportpluslib/email.tssupport mail delivery through Resend. - Support submissions route to
SUPPORT_INBOX_EMAIL; auth emails use transactional sender/reply-to config.
- Added dashboard support UI (
- Contacts:
- Added contacts CRUD APIs and dashboard contacts experience.
- Added
public/contacts-template.csvand related contact import/export UX support.
- Voice settlement refactor:
- Replaced row-based
CallHoldreservation model withCallSession+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.
- Replaced row-based
- 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 toinfo@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-smsas a protected Twilio REST backfill endpoint. - Recovery imports only inbound messages sent to owned
TwilioNumberrows and skips anyMessageSidalready present inSmsMessage.twilioMessageSid. - Backfilled SMS preserve Twilio's original
DateSent/DateCreatedas the stored message timestamp.
- Added
- 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/smswith a Buy credits CTA. - Added floor-skip counters in that banner (
negative_balance_floorskips in last 24h / 7d). - Added super-admin
/admin/sms-riskwith per-user risk rows and manual Send top-up email action. - Added super-admin
/api/admin/users/[id]/topup-reminderto manually send top-up reminder emails. - Updated inbound SMS debit policy to continue debiting while balance is negative, until
SMS_NEGATIVE_BALANCE_LIMITfloor is reached.
- Added a negative-balance warning banner in
- Wallet billing portal (local):
/dashboard/billinglists TOPUP-only rows (?billing=1onGET /api/user/wallet/transactions); invoice PDF viaPOST /api/billing/invoice(pdf-lib).
- Voice pricing + dashboard performance:
- CSV-first voice rates:
data/OutboundVoicePricing.csvsupplies 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:GETorPOST/api/twilio/refresh-voice-pricing-csv(app/api/twilio/refresh-voice-pricing-csv/route.ts, corelib/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.envvia@next/env). Cron:vercel.jsonschedules/api/twilio/refresh-voice-pricing-csvweekly (0 6 * * 0UTC); production usesCRON_SECRETasAuthorization: Bearer …(Vercel injects when configured).next dev: bearer optional for this route andsync-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 closingOutboundVoicePricing.csvin the editor or usingOutboundVoicePricing.csv.fresh(see route behavior). - Dashboard routing:
/dashboardrenders 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-creditsso the canonical page does data loading once. - Active billing context:
getPersonalWalletIdForUserIduses a short TTL in-memory cache plus in-flight dedupe to avoid repeatedwallet.upserton hot paths (lib/billing-context.ts). When the cookie still saysorgbutresolveRequestContextfalls back to personal, a short invalid-org cache skips repeat membership lookups;GET /api/context/activeandGET /api/user/walletmay rewrite the httpOnly cookie to personal so the browser stops sending stale org context (persistPersonalContextCookieIfStaleOrgCookieinlib/context.ts). - Auth:
getAuthSessionuses Reactcache()(lib/auth-session.ts). The NextAuthjwtcallback 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:
DialerPreviewcalls the lightweight/api/public/dialer-footer-previewwhen country rate is on but max-time footer is off; heavy/api/twilio/voice/max-duration-previewonly 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 whenRINGVOO_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, andVirtualNumberOrderfor common scoped queries (prisma/migrations/20260417163000_phase2_performance_indexes). - Twilio SDK:
TwilioProviderpreloads the Voice SDK only on phone/dialer routes. - Telemetry: opt-in
[Perf]...logs gated byRINGVOO_PERF_LOGS(see Logging); safe to leave off in production.
- CSV-first voice rates:
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 getOutboundVoiceEstimateForDestination → getRetailPerMinuteUsdForDestinationPrefix → getCarrierPerMinuteLongestDestMatchFromCsv |
| 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.tsapp/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(rebuilddata/OutboundVoicePricing.csvfrom 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: staleCallSessioncleanup / wallet sanity — cron-auth)app/api/twilio/call-holds/cleanup/route.ts(legacy endpoint; noCallHoldrows in current schema — safe no-op / future removal candidate)
- SMS:
app/api/twilio/sms/inbound/route.ts,app/api/twilio/sms-status/route.tsapp/api/sms/send/route.ts,app/api/sms/conversations/**,app/api/sms/logs/route.ts,app/api/sms/usage/route.tslib/sms-send.ts,lib/sms-inbound.ts,lib/billing/sms-pricing-lookup.ts,lib/billing/sms-config.ts
- Email / support:
app/api/support/route.tslib/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
- Outbound voice CSV refresh (bulk file):
- 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(sharedSTRIPE_CHECKOUT_ADAPTIVE_PRICINGhelper),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.tslib/client-billing-context.ts(getRingvooContextHeaders()returns{}— billing APIs use the httpOnlyringvoo_active_contextcookie only; avoids stalesessionStorageoverriding the cookie)lib/tax-id.ts(sanitizetaxIdOrVatfor DB + Checkout metadata)lib/auto-topup.ts(off-session top-up trigger after call settlement debits)lib/wallet.ts(creditWallet, call goodwillapplySystemRefundIfEligible)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(Checkoutmode: 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.tsxapp/api/numbers/search,create-checkout,retry-provisioning,manage-subscription,[id](PATCH)lib/stripe/virtual-number-checkout.ts,lib/stripe/virtual-number-webhook.tslib/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 toUser, organization wallets link toOrganization(organizationId).lastPurchaseAmount: most recent manual/auto top-up amount (USD); used for low balance mode detection (isVoiceLowBalanceModeinlib/billing/voice-wallet-credit.ts) alongsideRINGVOO_LOW_BALANCE_THRESHOLD_PERCENT. Voice admission itself isbalance > 0(see Voice admission). Wallet credits are always tied toWallet+Transaction; Stripe records which Customer was charged on each TOPUP row (stripeCustomerIdcolumn) — may be personal or org Customer id.Organization(billing-related):stripeCustomerId: Stripe Customercus_…for company-named org billing (created viagetOrCreateStripeCustomerForOrganizationinlib/stripe/org-customer.tson 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 viaGET /api/user/billing/auto-topupwhen 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 asUser). - Migration:
prisma/migrations/20260411210000_organization_stripe_pm_taxaddedstripeDefaultPaymentMethodIdandtaxIdOrVatonOrganization(older DBs: runnpx prisma migrate deploy). - Legacy tables removed:
prisma/migrations/20260411230000_drop_team_team_memberdropsTeam/TeamMember— multi-tenant membership isOrganization+OrgMemberonly.
Transaction: immutable credit/debit ledger, with a unique constraint@@unique([referenceId, source])for idempotency.- TOPUP rows: wallet credits from Stripe Checkout / auto top-up (
sourcee.g.stripe_checkout_session,stripe_auto_topup), welcome bonus, and call goodwillrefund(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).
- TOPUP rows: wallet credits from Stripe Checkout / auto top-up (
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-upPaymentIntentfails.Call: persisted Twilio call leg (billable SID), with:- Billing scope:
contextScope+contextId(PERSONAL+userId, orORG+ 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(whentrue, cost sync may adjust; default inbound CSV settlement usuallyfalse).
- Billing scope:
CallSession: one row per outbound/inbound voice attempt while the leg may still be active. TrackswalletId,userId, optionalorganizationId,twilioParentCallSid, optionalbillableCallSid,status(ACTIVE|ENDED), destinationestimatedRatePerSec/estimatedCostPerMinuteUsd(retail estimate fromgetOutboundVoiceEstimateForDestinationon outbound PSTN orgetInboundVoiceEstimateForIsoon 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 billablecallSidrecording 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 inperiodKey=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):CallHoldtable andCallHoldStatusenum — 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, orORG+ org id);userIdis 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
- Scope:
TwilioNumber: maps a Twilio phone number to a RingvoouserIdand a billing scope (contextScope,contextId). Used for inbound voice routing, SMS, and virtual-number linkage; wallet for SMS debits followscontextScope/contextIdviaresolveWalletIdForTwilioNumberRow(lib/billing-context.ts).VirtualNumber: one row per purchased virtual number subscription — linksUser↔TwilioNumber, Stripe subscription id, billing period, status (active,past_due, etc.); includescontextScope/contextIdfor org vs personal purchases.VirtualNumberOrder: checkout + provisioning audit; includescontextScope/contextId(Stripe metadata + provisioning).- SMS (see SMS (Twilio Messaging) and SMS (per workspace)):
SmsConversation: thread per(userId, twilioNumberId, remoteNumber)and scoped bycontextScope/contextId(list APIs filter by active context).SmsMessage: each message; same scope columns; storessegmentCount,ratePerSegment,billedAmount, Twilio provider cost fields,twilioMessageSid,status, wallet linkage.SmsCountryPricing: per ISO country, direction (inbound|outbound), provider Twilio; storesproviderPricePerSegment,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 byPOST /api/context/active(30-day cookie,SameSite=lax,Securein production).resolveRequestContext(userId, { cookieRaw?, headerRaw? }):- Parses optional header
x-ringvoo-contextfirst: compactpersonal|<userId>ororg|<orgId>(parseContextHeader). If absent, parses the cookie (parseCookieJson). - Personal:
contextId === userId,walletIdfromgetPersonalWalletIdForUserId(lib/billing-context.ts/lib/wallet.ts). - Org: loads
OrgMember(ACTIVE) for(userId, orgId)including orgwallet. If missing or org has no wallet, falls back to personal (never silently infers org). orgRole: from membership, passed throughsyncOrgMemberRoleWithBillingOwner(lib/org-membership.ts) so the billing owner row stays consistent withOrganization.ownerUserId.
- Parses optional header
- Helpers:
canShowPromoInContext— promos on Buy credits only in personal or when org role is privileged (lib/org-permissions.tsorgRoleIsPrivileged).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):getPersonalWalletIdForUserIdwrapsprisma.wallet.upsertwith a ~15s in-process TTL cache and in-flight dedupe peruserId. 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 saystype: "org"but membership/org wallet resolution fails and the code falls back to personal, a ~10s in-memory cache keyed byuserId:orgIdskips repeatingorgMember.findFirston every request until TTL expires (then membership is checked again). - Cookie normalization (Route Handlers only):
persistPersonalContextCookieIfStaleOrgCookieruns afterresolveRequestContextonGET /api/context/activeandGET /api/user/wallet. If resolution is personal but the httpOnly cookie still says org, the handler sets the cookie to{"type":"personal","id": userId}usingRINGVOO_CONTEXT_COOKIE_SET_OPTIONS(same shape asPOST /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/emailVerifiedthrough a ~1s cache + in-flight dedupe per user id. Session invalidation viasessionVersionbump still works because stale cache entries expire quickly. RINGVOO_PERF_LOGS: Whentrue, selected pages and APIs log[Perf]...timings to the server console for debugging. Default off in normal runs. Devnext devcompile times still inflate wall-clock; usenext build && next startor 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:
UserMenucallsPOST /api/context/activewith{ type, id }, writes the returned compact header (personal|...ororg|...) tosessionStorage.ringvoo_ctx_header, updates localactiveCtxoptimistically, then dispatcheswindow.dispatchEvent(new Event("ringvoo-context-changed")). - Why both cookie + session storage exist: the cookie is authoritative for server resolution (
resolveRequestContext), whilesessionStorage.ringvoo_ctx_headeris a client mirror for synchronous browser-side consumers (e.g. Twilio connect params), not for billing API authority. - Wallet/provider refresh path:
WalletBalanceProviderlistens forringvoo-context-changed, shows a switching overlay, refreshes/api/user/wallet, refreshes/api/context/active(to mirror the latest compact header), and callsrouter.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 stalesessionStorageoverriding 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: name → Organization + 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 tocanInviteWithRole,canChangeMemberRole,canRemoveMember. - OWNER on
OrgMembertracks membership;Organization.ownerUserIdis 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 Prismawherefragment for models keyed by workspace:- Personal:
{ userId, contextScope: PERSONAL, contextId: userId }. - Org:
{ contextScope: ORG, contextId: orgId }— nouserIdfilter (shared org data; membership is enforced earlier inresolveRequestContext).
- Personal:
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
resolveRequestContextwithheaderRaw: req.headers.get("x-ringvoo-context")so list/create/update targets the active workspace. - Rows are filtered with
prismaScopedWhereForContext; verification updates includecontextScope/contextIdso 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/conversations — resolveRequestContext + 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
deleteOrganizationScopedDatadeletes rows with{ contextScope: ORG, contextId: orgId }for:SmsMessage,SmsConversation,VirtualNumberOrder,VirtualNumber,TwilioNumber,Call,UserCallerId, and org-scopedPromorows.- 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):
- Twilio hits the TwiML URL when the browser places a call via Twilio.Device.
- 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)
- We resolve the user from identity and org membership (if org billing).
- Admission + rate estimate (no
CallHoldrows — see Voice admission):- Load destination ISO country (
getIsoCountryFromPhoneNumberon calleeTo). getOutboundVoiceEstimateForDestination()(lib/billing/voice-call-estimator.ts): retail $/min from longest CSV destination-prefix match onTo, with CSV origination filtered by the PSTN callerId chosen for<Dial>(public / owned number / verified custom) —getRetailPerMinuteUsdForDestinationPrefixinlib/billing/twilio-voice-pricing.ts.validateVoiceCallStart()→evaluateVoiceCallLiquidity()(lib/voice-call-validation.ts): block whenwallet.balance ≤ 0; otherwise returnslowBalanceModefromisVoiceLowBalanceMode()(lib/billing/voice-wallet-credit.ts).creditLimitUsdis always0(no negative-balance extension).
- Load destination ISO country (
- Org member quota (role
MEMBERonly):assertOrgMemberVoiceQuotaAllowsCall()withprojectedChargeUsd = 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). - We create a
CallSession(createVoiceCallSessioninlib/voice-call-sessions.ts) with the estimate and parent SID;createOrRefreshCallHold()is still invoked but is a no-op (legacy hook). - 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()usingminAllowedSeconds = 1,safetyBufferSeconds = 0, then pick the largest configured step ≤ that budget (RINGVOO_LOW_BALANCE_TIME_LIMIT_STEPS_SECONDS, default60,120,180). If budget is below the smallest step, TwiML still uses the smallest step (product policy).
- Normal balance: cap =
- We return TwiML that dials the destination with:
timeLimitfrom step 7statusCallbackandrecordingStatusCallback(whenSERVER_URLis 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:
- Validate Twilio webhook signature (
validateTwilioWebhook()inlib/twilio.ts). - Parse
From,To(the Twilio number that was called), andCallSid. - Map
To→ owning Ringvoo user usingTwilioNumbertable viagetUserFromTwilioNumber()inlib/twilio.ts. - Return TwiML:
- spoken “Please hold while we connect your call.” (
<Say>) <Dial><Client identity="<userId>" /></Dial>- adds
statusCallbackandrecordingStatusCallbackifSERVER_URLexists
- spoken “Please hold while we connect your call.” (
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 behttp://orhttps://) - 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 usingRINGVOO_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
Callrow. - Debit wallet (retail) when pricing is resolved; or provisional estimated debit +
CallSettlementwhencompletedbut price missing (see Voice call sessions). - Apply refund rules and org member quota usage deltas on debits/refunds.
- Advisory lock per billable SID (
lockCallSettlementinlib/pg-advisory-lock.ts) inside the transaction to reduce duplicate webhook write conflicts. - End matching
CallSessionrows (endVoiceCallSessionsForTerminal). - Legacy
settlePendingHoldForCall/CallHoldhooks 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).
- If Twilio provides
- Terminal-only processing:
- Only processes:
completed,busy,failed,no-answer,canceled. - Non-terminal statuses are acknowledged but skipped.
- Only processes:
- Second fetch for “completed but price missing”:
- If terminal is
completedand RESTpriceis empty, waits ~1500ms and fetches again.
- If terminal is
- Owning user resolution:
- Tries to map by Twilio numbers (
getUserFromTwilioNumber(from|to)). - If not found, tries Twilio client identity parsing (
client:<userId>).
- Tries to map by Twilio numbers (
Settlement entry point:
upsertCallAndSettleWallet()inlib/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 / 60fromgetOutboundVoiceEstimateForDestinationon outbound PSTN orgetInboundVoiceEstimateForIsoon inbound — see Voice rate usage matrix). billableCallSidis filled later viabindVoiceCallSessionBillableSid()when the child leg SID is known (matches parent or orphanbillableCallSidpatterns).- Ended on terminal
call-status:status = ENDED,endedAtset —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 alreadyENDED(logged warning) to avoid stuckACTIVErows.
CallSettlement (idempotency + provisional → final)
- Unique on
callSid(billable leg). RecordsamountUsd(retail charged to wallet) and optionaltwilioCarrierCostUsd. - When Twilio price exists on
completed: normal path debitsTransactionwithreferenceId = billableCallSid, createsCallSettlementwith carrier + retail, appliesapplySystemRefundIfEligibleshort-call rules. - When Twilio price is missing on
completed:upsertCallAndSettleWalletdebits an estimated amount =estimatedRatePerSec × durationfrom the matchingCallSession,referenceId = <sid>:estimated,CallSettlementrow withtwilioCarrierCostUsd = null,Call.billingStatus = settled_estimated. When a later callback/job provides price,existingSettlement.twilioCarrierCostUsd === nulltriggers delta debit or refund (:final-delta/:final-delta-refund) and updatesCallSettlement+Call.costtosettled. - Org member quota:
applyMemberQuotaDeltaIfApplicable()runs on each real debit/refund delta for orgMEMBERrole only — updatesOrgMemberVoiceQuotaUsagefor the current UTC month (periodKey).
Org member voice quota (admin-configured)
- Tables:
OrgMemberVoiceQuota(limit + enabled),OrgMemberVoiceQuotaUsage(perYYYY-MM). - Pre-call gates:
assertOrgMemberVoiceQuotaAllowsCall()— same projected charge as admission uses (estimatedCostPerMinuteUsdper minute) for: TwiML outbound (/api/twilio/voice/outbound), dialer preflightPOST /api/voice/call-start, andGET /api/twilio/voice/max-duration-preview(preview zerosavailableBalanceUsd/ marksinsufficientwhen quota blocks). - Errors:
OrgMemberQuotaNotConfiguredError(no row, disabled, ormonthlyLimitUsd ≤ 0) — members cannot place calls until fixed;OrgMemberQuotaExceededErrorwhen 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-startmirrors 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()returnsavailableUsd = wallet.balance(active holds = 0);expirePendingCallHolds/createOrRefreshCallHold/settlePendingHoldForCall/compressPendingHoldForCallare 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
completedcall hastwilioCostUsd === null,call-statusenqueues a job:enqueueCallCostSyncJob()inlib/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
- billing itself is idempotent via Transaction
kickDueCostSyncJobsInBackground()runs a few quick local retries (dev convenience).GET /api/twilio/sync-cost-jobsprocesses due jobs (cron-friendly). Jobs are skipped (no-op complete) for inbound calls already settled from CSV whenRINGVOO_INBOUND_VOICE_PRICE_SOURCEis nottwilio, so reconcile does not overwrite table-based inbound charges (lib/call-cost-sync.ts).GET/POST/api/twilio/refresh-voice-pricing-csvrebuildsdata/OutboundVoicePricing.csvfrom Twilio Pricing API v2 (scheduled invercel.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 marksUserCallerId.verified.- Legacy routes (
/api/twilio/custom-caller-id/voice,confirm,status) are no longer used byPOST /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
- User submits a caller ID number (
POST /api/user/caller-ids). - Server normalizes and validates E.164 (
++ 8-15 digits), checks uniqueness, and enforces cooldown (nextAttemptAt, currently 30s). - 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).
- Otherwise the server calls Twilio
validationRequests.create(Outgoing Caller ID API). Twilio returns a 6-digitvalidationCodesynchronously and places its own verification call to the user’s number. The response includes the verification Call SID. - Ringvoo stores the code in
UserCallerId.verificationCode(for display in the modal),verificationCallSid,expiresAt(~10 minutes), andSERVER_URL-basedstatusCallbackpointing atPOST /api/twilio/outgoing-caller-id/validation-status. - The user answers Twilio’s call and enters the code when prompted. Twilio then adds the number to Verified Caller IDs for the account.
- Twilio invokes the status callback with
VerificationStatus(success|failed). On success, Ringvoo setsverified = trueand clears verification fields. GET /api/user/caller-idsalso 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
429withRetry-After. - Verification call creation failures return
502to 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
twilioAccountAuthorizesCallerIdwhen voice logging is enabled. SetTWILIO_FALLBACK_CALLER_ID_IF_NOT_TWILIO_AUTHORIZED=trueonly if you want to force fallback toTWILIO_PHONE_NUMBERwhen Twilio does not authorize the selected ID.
CallFromMode (dialer request → outbound TwiML)
The dialer sends CallFromMode to POST /api/twilio/voice/outbound:
public→ useTWILIO_PHONE_NUMBERphone→ use selected owned number (CallFromNumber)custom→ use selected verified custom caller ID (CallFromNumber)
Key outbound logs:
callFromModepstnCallerIdtwilioAccountAuthorizesCallerId(forphone/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'andtwilioAccountAuthorizesCallerId: 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
- label matches owned number → force
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 scopecontextScope/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: Storesbody,direction(inbound|outbound),segmentCount,encodingType(gsm7|ucs2), TwiliotwilioMessageSid,status,ratePerSegment,billedAmount, provider cost fields,walletDebited, optionalbillingSkippedReasonwhen pricing or debit was skipped; includescontextScope/contextIdfor org-aware reporting.SmsCountryPricing: One row per(countryIso, direction, provider)withproviderPricePerSegment, snapshotmarkupMultiplier,minimumPriceFloor, computedretailPricePerSegment,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 thesms-lengthlibrary →segmentCount(minimum 1) and GSM-7 vs UCS-2 (UTF16→ucs2). - Inbound: Prefer Twilio’s
NumSegmentsfrom the webhook when it parses as a finite integer ≥ 1; otherwise fall back tocomputeSmsSegments(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:
countryIsois derived from the destinationTonumber (getCountryIsoFromE164(normalizedTo)). - Inbound:
countryIsois derived from the senderFromnumber (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: outboundSMS_OUTBOUND_MARKUP_MULTIPLIER(default2.0), inboundSMS_INBOUND_MARKUP_MULTIPLIER(default2.0).F= direction-specific floor: outboundSMS_OUTBOUND_MINIMUM_FLOOR_USD(default0.0100), inboundSMS_INBOUND_MINIMUM_FLOOR_USD(default0.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(runstsx scripts/import-sms-pricing.ts→runSmsPricingImport()). - Source file:
data/SMSPricing.csv— columnsISO,Country,Description,Price / msg; only rows whose description containsoutbound(case-insensitive) are used. - Aggregation: For each ISO, Twilio’s maximum outbound
Price / msgacross those rows becomesproviderPricePerSegmentfor that country’s outbound row (conservative vs multiple carriers in the file). - Inbound provider seed: The CSV does not include inbound rates. Inbound
providerPricePerSegmentis seeded from the same max outbound provider rate per country (documented insourceLabelon 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
- Destination guard: Outbound sends are allowed only to US and Canada (
+1E.164); seeisUsCanadaSmsDestination()inlib/sms-destination.ts. - Ownership:
fromNumbermust be aTwilioNumberowned by the user. - Segments + pricing:
segmentCountfromcomputeSmsSegments(body);findActiveSmsPricing({ countryIso: dest, direction: "outbound" }). - Retail total:
billed = retailPricePerSegment × segmentCount(from the active outbound row). - Balance check:
getAvailableBalanceUsd()must be ≥billed(equals gross wallet balance; voice does not subtract row-level holds). - Twilio:
sendSmsViaTwilio— on success, aSmsMessagerow is created anddebitWalletruns in the same DB transaction withsource: "sms_outbound",type: "SMS",referenceId: twilioMessageSid.walletDebitedis set true when the debit succeeds. - 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
- Webhook:
POST /api/twilio/sms/inboundvalidates the Twilio signature, thenprocessInboundSms()inlib/sms-inbound.ts. - Dedup: Duplicate
MessageSid/SmsSidreturns success without double-inserting. - Ownership:
Tomust match aTwilioNumber.phoneNumber(the user’s inbound number). - Segments:
NumSegmentsfrom Twilio if valid, elsecomputeSmsSegments(body). - Pricing:
findActiveSmsPricing({ countryIso: senderCountry, direction: "inbound" })wheresenderCountry=getCountryIsoFromE164(From). - Retail total: If pricing exists,
billed = retailPricePerSegment × segmentCount. If no row, the message is still stored withbillingSkippedReason/ null billing fields as appropriate. - User alert email: after a successful insert,
sendInboundSmsAlertEmail()notifies the owning user email; alert mail usesALERT_FROM_EMAILand replies route toALERT_INBOX_EMAIL. - Recovered/backfilled SMS: when imported via
/api/twilio/sync-sms, the same processor runs with Twilio REST fields mapped into webhook-shaped params, preserving originalDateSent/DateCreatedso the inbox/history timestamp matches provider event time rather than sync time. - Optional auto-reply SMS: if
SMS_INBOUND_AUTO_REPLY_ENABLEDis on and no priorSmsConversationexists for(userId, twilioNumberId, remoteNumber), Ringvoo sends one outbound reply from the purchased number back to the inbound sender usingsendAutoReplySms().
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()inlib/wallet.ts:- If
SMS_ALLOW_NEGATIVE_BALANCEis false/0: behaves likedebitWalletwithonInsufficient: "skip"— no debit if balance insufficient; message still stored;walletDebitedfalse with a skip reason. - If enabled (default): allows debiting until wallet balance would drop below
SMS_NEGATIVE_BALANCE_LIMIT(default-5USD), including when balance is already negative. If the debit would cross that floor, debit is skipped (below_floor/negative_balance_floor).
- If
- Idempotency:
referenceId= Twilio message SID,sourcesms_inbound/sms_outboundwith 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(seelib/twilio.tswhen 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. latesentafterdelivered) or replace a terminal failure with a non-terminal status.- If the
SmsMessagerow is not found yet, the handler retries briefly (race with send transaction).
Dashboard routes and product rules
- Inbox / compose:
/dashboard/sms— send usesPOST /api/sms/sendwith 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_floorskips over 24h/7d).
- When available balance is negative, the page shows a warning banner with a Buy credits link and floor-skip counters (
- Logs:
/dashboard/sms/logs— readsGET /api/sms/logs(pagination query params as implemented). - Usage:
/dashboard/sms/usageredirects to/dashboard/sms(usage data may still exist viaGET /api/sms/usagefor future admin/analytics). - Alias:
/dashboard/messagesredirects to/dashboard/sms. - No owned number: The inbox surfaces a CTA when the user has no
TwilioNumber; displayed monthly number price copy usesRINGVOO_NUMBER_MONTHLY_PRICE_USD(seelib/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()andsplitTwilioAndRetailCharge()inlib/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 })inlib/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_pricesvsoutboundPrefixPricesdestination_prefixesvsdestinationPrefixescurrent_pricevscurrentPrice
- 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 (
computeVoiceDialTimeLimitSecondsinlib/voice-dial-time-limit.ts) overrides tominAllowedSeconds = 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
lowBalanceModeis true:safeMaxSeconds = min(pickStep(maxAllowedSeconds), RINGVOO_VOICE_MAX_CALL_SECONDS cap) - when
lowBalanceModeis 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 admissionestimatedCostPerMinuteUsd— country max ingetOutboundVoiceEstimateForIsoexamples; live PSTN uses prefixgetOutboundVoiceEstimateForDestinationwhen applicable)
Math:
maxAllowedSeconds = floor(0.5933 / 0.189 * 60) = 188- Low-balance stepping picks the largest step
≤ 188→180s - TwiML
<Dial timeLimit>becomesmin(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). creditLimitUsdis always0(computeVoiceCreditLimitUsdinlib/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 whenwallet.balance ≤ lastPurchaseAmount × RINGVOO_LOW_BALANCE_THRESHOLD_PERCENT(default0.02). - If there is no purchase history (
lastPurchaseAmount = 0): anywallet.balance > 0is treated as low balance mode (protects welcome credit / first-call scenarios).
CallSession fields vs pre-call gating
evaluateVoiceCallLiquiditystill returnsactiveSessions, butestimatedExposureUsdis currently0(concurrency is not priced into the pre-call gate).CallSessionremains important for estimated settlement when Twiliopriceis 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) frompickLowBalanceStepSeconds(then clamped byRINGVOO_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
Transactionrow withtype=CALLortype=SMS - decrements
Wallet.balance - idempotent: if
referenceIdalready exists for the samesource, 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
Priceon parent legs never bills. - Outbound
completed: if Twilio webhook/REST provides aPrice, default path usestwilio_webhook(carrier ×RINGVOO_VOICE_RETAIL_MULTIPLIERviasplitTwilioAndRetailCharge). If price is missing, CSV longest-prefix carrier × ceil(billable seconds / 60) × multiplier (voicePricingSourcecsv_prefix; may setneedsVoiceReconcilefor later REST reconcile). - Inbound
completed(default): whenRINGVOO_INBOUND_VOICE_PRICE_SOURCEis unset or nottwilio, Twilio’sPriceon 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 DIDTo, origination = PSTNFrom, with fallback to countrystartingAt;voicePricingSourcecsv_prefix,needsVoiceReconciletypicallyfalse. SetRINGVOO_INBOUND_VOICE_PRICE_SOURCE=twilioto bill inbound from Twilio like outbound. - Computes:
twilioCost(carrier side stored onCall/ settlement — for CSV paths this is the synthetic carrier aligned to the retail split)retailCharge(customer debit)
- Writes/updates
Callrow with:twilioCostif knowncostonly once debited (or explicit zero settled cases)voice_pricing_source,initial_voice_pricing_source,needs_voice_reconcilewhere applicable
- Debits wallet when:
- status is
completed - and retailCharge is known and > 0
- status is
CallSettlementrow for idempotency; estimated path whencompletedbut price missing (see Voice call sessions).- Binds
CallSessionbillable SID; ends active sessions on terminal. - Duration fallback chain: Twilio duration → internal timestamps /
CallSession/ existingCallrow. - May operate on provisional
Callrows (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
completedAND Twilio carrier cost was actually positive - this prevents subsidizing carrier charges for answered-but-immediately-hung-up calls
- status is
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
SerializablePrisma transactions in critical spots;call-statusretries on transient write conflicts afterpg_advisory_xact_lockon the billable SID (lib/pg-advisory-lock.ts). - Idempotent debits: unique index on
Transaction(referenceId, source)ensures:- repeated callbacks for the same
callSiddo not double-charge
- repeated callbacks for the same
- Idempotent call upsert:
Callrow is upserted bytwilioCallSid. CallSettlement: unique oncallSid; 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
twilioCallSidwithout 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.organizationId → Organization) |
| Manual “Buy credits” Checkout | POST /api/billing/checkout-session + resolveRequestContext → targetWalletId, 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.
Active billing context (cookie and API headers)
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 byPOST /api/context/active.resolveRequestContext(lib/context.ts) prefers request headerx-ringvoo-contextif present, else cookie. Client fetches must not send a stalex-ringvoo-contextfromsessionStoragefor wallet/billing routes:getRingvooContextHeaders()returns{}so the server uses the cookie only (lib/client-billing-context.ts). sessionStorageringvoo_ctx_header: Still written on context switch and onWalletBalanceProvidermount (GET /api/context/active) for code that reads it synchronously (e.g. Twilio connect params inDialerPreview), not for overriding billing APIs.- Switching workspace:
components/dashboard/UserMenu.tsx— POST, updatesessionStorage+activeCtxoptimistically,window.dispatchEvent("ringvoo-context-changed"); listeners refresh wallet, Buy credits auto-topup fetch, etc.
Manual top-up (Buy credits)
-
User opens
app/dashboard/buy-creditsand clicks Secure checkout (optional Tax ID / VAT is sanitized vialib/tax-id.tsand saved toUser.taxIdOrVat(personal) orOrganization.taxIdOrVat(org) on each checkout POST, and copied into Checkout session metadata astaxIdOrVat— Ringvoo does not use it to calculate tax unless you enable Stripe automatic tax later). -
Minimum wallet top-up is $5 USD (enforced in
POST /api/billing/checkout-sessionviavalidateWalletTopUpAmountinlib/stripe/checkout-session.ts). The Buy credits UI clamps under-minimum custom amounts on blur and before starting checkout; see Dashboard billing UI. -
POST /api/billing/checkout-sessioncreates a Stripe Checkout Session (mode: payment) withcustomer= personalgetOrCreateStripeCustomeror orggetOrCreateStripeCustomerForOrganization(see Organization wallet vs Stripe Customer). Dynamicline_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 itemproduct_data.name= human-readableCall Credits × {usd}(lib/stripe/call-credits-stripe-copy.ts),payment_intent_data.metadata:type: "topup",credits(=walletCreditUsdstring),userId, existing keys (ringvooUserId,kind,walletTopupType, …). Session metadata:ringvooUserId,userId,walletCreditUsd,taxIdOrVat,ringvooWalletScope,ringvooTargetWalletId,ringvooOrganizationId(when org), auto top-up fields. -
User pays on Stripe-hosted Checkout; success redirect includes
checkout=success(wallet may still be updating until webhooks finish). -
For
mode: paymentsessions withpayment_status = paid, webhookscheckout.session.completed/checkout.session.async_payment_succeededretrieve the session (expandedpayment_intent,setup_intentas needed), then:- In a DB transaction (when the Stripe event id is new):
creditWallet()+StripeWebhookEventinsert. - Then
syncDefaultPaymentMethodFromWalletCheckout: sets Stripe Customerinvoice_settings.default_payment_methodand mirrorsstripeCustomerId+stripeDefaultPaymentMethodIdonUser(personal) orOrganization(org) when a payment method is present. - Then
applyCheckoutUserSettings: updates auto top-up toggles and amounts onUserorOrganizationperringvooWalletScope. Does not clear the saved PM when auto top-up is off.
- In a DB transaction (when the Stripe event id is new):
-
creditWalletusessource = stripe_checkout_sessionandreferenceId = session.id(idempotent with@@unique([referenceId, source])). -
fundingSourceon theTransactionrow ismanual_checkout; TOPUP rows store Stripe audit fields;stripeCustomerIdon the transaction reflects the charged Customer (personal or org). -
After wallet credit + idempotent event handling, first-purchase reward and promo usage (see Promotion codes — wallet top-up):
issueFirstPurchaseRewardIfEligible, then eithermarkUserCouponUsedFromCheckoutSession(metadataringvooUserCouponId) ormarkUserCouponUsedFromCheckoutSessionDiscounts(promo entered on Hosted Checkout only). Coupon marking runs only when the session is a wallet top-up (walletTopupTypeor legacywalletCreditUsdmetadata). Org sessions skip first-purchase + coupon mark-used paths whenringvooWalletScope === 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:descriptionmatches that line title;receipt_email= buyer email so Stripe-hosted successful payment emails target the same address;metadataincludestype: "topup",credits,userId(plus legacyringvooUserId,kind,walletCreditUsd, wallet scope, org id when applicable — seelib/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: fromisStripeCheckoutAdaptivePricingEnabled()inlib/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_emailand human-readabledescription/ line items so Stripe’s PDF-like emails showCall 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(orCHECKOUT_META_RINGVOO_USER_COUPON_ID) is in metadata →markUserCouponUsedFromCheckoutSession. - Else →
markUserCouponUsedFromCheckoutSessionDiscounts: readssession.discountsand matchespromotion_codetoUserCoupon.stripePromotionCodeIdfor 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 (issueFirstPurchaseRewardIfEligible → stripe.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
Inputerrorstate 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.tsxrunsresolveRequestContextand passesinitialBillingContextintoBuyCreditsClientso tier (default vs enterprise) and default preset ($20 vs $500) match the active workspace on first paint.GET /api/user/walletuses cookie-only context (credentials: "same-origin";getRingvooContextHeaders()does not sendx-ringvoo-contextfromsessionStorage) — 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-topupstill returnshasSavedPaymentMethodfor 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
UserCouponis 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 whenallow_promotion_codesis 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-topupreturns403for 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
hasSavedPaymentMethodis 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 viaPOST /api/context/activeand broadcasts context change before redirecting.
Billing portal: credits and invoices
- Route:
/dashboard/billing—BillingPortalClient(components/dashboard/billing/BillingPortalClient.tsx). - Workspace: Uses
resolveRequestContextvia the same httpOnlyringvoo_active_contextcookie 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} fromGET /api/context/active).ringvoo-context-changedrefetches transactions. - List scope:
GET /api/user/wallet/transactions?billing=1returns onlyTransactionType.TOPUProws (manual checkout, auto top-up, welcome credit, call goodwill credits withsource = "refund", etc.). Omitbilling=1for 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 prefersSUPPORT_INBOX_EMAIL(fallbacksupport@ringvoo.comin code). Invoice number on the PDF isRV-+ suffix derived from theTransaction.id(not a separate stored invoice row — same transaction always gets the same number). - PDF styling: Header embeds
public/favicon.pngas 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
canDownloadWorkspaceInvoicepasses (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) — listsPaymentrows (wallet top-ups tracked inPayment/Transaction). - Action: Refund →
POST /api/admin/payments/refundwithpaymentIntentIdand optionalamountUsd/reason(app/api/admin/payments/refund/route.ts). - Server:
lib/admin/stripe-refund-request.ts→stripe.refunds.createwith idempotency keyrefund_admin_{pi}_{cents};Payment.status→refund_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.createdandcharge.refunded:lib/stripe/webhook-handlers.tsdispatches toapplyStripeRefundToLedger(lib/stripe/refund-webhook.ts).- Creates a
STRIPE_REFUNDledger row,debitWalletForStripeRefund, updatesPayment.refundedAmount/status(refunded|partial_refund). Idempotent per Striperefund.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:
- UI (e.g. Settings or Buy credits) calls
POST /api/billing/setup-payment-methodwith threshold/amount andreturnTo(settings|buy-credits). Route resolvesresolveRequestContext; org + privileged → passesorganizationIdintocreateAutoTopupPaymentMethodSetupSessionso the Setup session uses the organization Stripe Customer (lib/stripe/setup-checkout-session.tsmetadata:ringvooOrganizationId,ringvooWalletScope: org). - Creates Checkout
mode: setupwith metadatakind = auto_topup_setup_checkoutand auto top-up amounts. - On
checkout.session.completedformode: setup, webhook handlerhandleSetupCheckoutSessionCompleted(nocreditWallet) attaches the SetupIntent’s payment method to the Customer, setsUserorOrganizationPM + auto top-up fields depending onringvooOrganizationIdin metadata. - Redirect query
billing_setup=success|billing_setup=canceleddepending onreturnTo.
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(fromRINGVOO_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/numbersand/dashboard/numbers/successredirect 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-checkoutcreates aVirtualNumberOrder, thencreateVirtualNumberCheckoutSession. - Mode:
subscription. - Line items — dynamic
price_data+product_data: The session does not use only the DashboardPriceid for the line item text. The codestripe.prices.retrieve(STRIPE_VIRTUAL_NUMBER_PRICE_ID)and buildsline_itemswithprice_datamatching the catalog price’scurrency,unit_amount,recurring,tax_behavior, and copiesproduct.tax_codewhen present.product_datasets 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)—XfromRINGVOO_NUMBER_MONTHLY_PRICE_USDvialib/ringvoo-number-price.ts(ringvooPhoneNumberMonthlyStripeTitle()). - Description:
Phone Number Monthly Fee — {display}(RINGVOO_PHONE_NUMBER_LINE_DESCRIPTION+ NANP-spaced E.164 fromformatE164ForDisplay()inlib/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_checkouttags 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 subscriptiondescriptionafter Twilio assigns the number (lib/stripe/virtual-number-webhook.ts).metadatamirrors session + order linkage.metadataon the Checkout Session: same feature / user / order /selectedNumberfields for webhook routing.allow_promotion_codes: not set — no promotion code field on Hosted Checkout for virtual numbers.adaptive_pricing: fromlib/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.tsandlib/stripe/webhook-handlers.ts: whenmetadata.feature === virtual_number(STRIPE_METADATA_FEATURE_VIRTUAL_NUMBERinlib/stripe/constants.ts), complete provisioning (Twilio purchase,VirtualNumber+TwilioNumber, order status).invoice.paid(virtual numbers):handleVirtualNumberInvoicePaidrecords the Stripeevent.idinStripeWebhookEventbefore side effects so duplicate deliveries are ignored (idempotent replays).- Test mode:
resolveTwilioPurchaseE164maps magic test numbers to Twilio’s test success line; seelib/twilio/virtual-number-service.ts.
Virtual number subscription billing (failed payments & Stripe cancellation)
- Monthly amount (UX label): driven by
RINGVOO_NUMBER_MONTHLY_PRICE_USDinlib/ringvoo-number-price.ts(defaults to 1.95 USD if unset/invalid). Stripe’s recurring charge uses the catalog Price behindSTRIPE_VIRTUAL_NUMBER_PRICE_IDforunit_amount/recurringwhen building Checkoutline_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 matchingVirtualNumber.statustopast_due.customer.subscription.updatedalso syncs Stripe subscription status: Stripepast_due/unpaid→ DBpast_due. - In-app grace behavior: For SMS/voice eligibility, the codebase treats
activeandpast_duethe 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 ispast_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,handleVirtualNumberSubscriptionDeletedruns:releaseIncomingNumber(vn.twilioSid)(Twilio — errors are logged; DB cleanup still proceeds), then a Prisma transaction deletes theVirtualNumberrow and the linkedTwilioNumberrow. 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=trueis for deterministic local simulation of number search/purchase and provisioning outcomes.- In that mode, provisioning intentionally bypasses live Twilio purchase and persists simulated
VirtualNumber.twilioSidvalues prefixed withPNTEST.... - 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_TOKENmatch the account that owns the number - ensure
VirtualNumber.phoneNumberandTwilioNumber.phoneNumberare E.164 and aligned - ensure
VirtualNumber.twilioSidis a real Twilio IncomingPhoneNumber SID (PN..., notPNTEST...)
- set
- 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.
Related API routes
| 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; alsostripeCustomerId+stripeDefaultPaymentMethodIdwhen the org has completed org Checkout or org setup (see Organization wallet vs Stripe Customer). - Off-session charge (org):
runMaybeTriggerAutoTopupOrgusesOrganization.stripeCustomerId+Organization.stripeDefaultPaymentMethodIdwhen both are set. Legacy: if the org never saved a card on the org Customer, falls back to owner’sUser.stripeCustomerId+User.stripeDefaultPaymentMethodIdso existing deployments keep working until the next org billing action. - Settings API:
GET /api/user/billing/auto-topup— personal:hasSavedPaymentMethodfromUser.stripeDefaultPaymentMethodId,taxIdOrVatfrom User. Org:hasSavedPaymentMethodtrue if org has both Customer + PM or legacy owner/user PM paths match;taxIdOrVatprefersOrganization.taxIdOrVat, then viewer’s User.PATCH /api/user/billing/auto-topuppersists threshold/amount on User or Organization by context (no Stripe redirect on PATCH). $5 minimum on amount when enabling. - Trigger:
maybeTriggerAutoTopupAfterDebit(actorUserId, walletId)inlib/auto-topup.tsafter wallet debits (calls, SMS). LegacymaybeTriggerAutoTopup(userId)→ personal wallet only. Call path triggers include:app/api/twilio/call-status/route.tslib/call-cost-sync.tsapp/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’spayment_intent.succeededalone — avoids double-credit). - Credit:
payment_intent.succeeded(auto top-up PI) →creditWalletwithsource = stripe_auto_topup,referenceId = pi_…,fundingSource = auto_topup. Handler acceptsuserIdin metadata ifringvooUserIdwere ever omitted (lib/stripe/webhook-handlers.ts). - Failure:
payment_intent.payment_failed→StripeAutoTopupFailure; inflight cleared onOrganizationorUserdepending on wallet scope.
Supporting tables
StripeWebhookEvent: Stripe event id (evt_…) to avoid duplicate processing alongsideTransactionidempotency.StripeAutoTopupFailure: failed off-session auto top-up attempts (wallet not credited).
Related API routes
| 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 forceinsufficientand zero the preview’s effectiveavailableBalanceUsd)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
- Primary active verification callback:
/api/twilio/sync-cost-jobs?limit=50/api/twilio/refresh-voice-pricing-csv(rebuilddata/OutboundVoicePricing.csv; same auth pattern as sync-cost-jobs; or runnpm run refresh-outbound-voice-pricing-csvlocally)/api/twilio/call-holds?limit=100//api/twilio/call-holds/cleanup?limit=500(legacy — noCallHoldtable)/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:
- User is authenticated in dashboard and Twilio Device is online.
- Destination number is present and normalized.
- Wallet admission:
evaluateVoiceCallLiquidity()— block whenwallet.balance ≤ 0; otherwise allow (see Voice admission).CallSessionexposure is not currently subtracted in pre-call gating (estimatedExposureUsdis0). - Org members:
assertOrgMemberVoiceQuotaAllowsCall()— enabled quota with positive monthly limit and enough UTC month headroom (projected charge = per-minute estimate). - 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) aftervalidateVoiceCallStart(). - Normal balance: env
RINGVOO_VOICE_MAX_CALL_SECONDS(default 86400, clamped) — effectively unlimited for typical calls. - Low balance mode: uses
computeMaxDurationDecisionFromRate()withminAllowedSeconds = 1,safetyBufferSeconds = 0, wallet budget =usableFundsUsd, then appliesRINGVOO_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
CallHoldrows — admission iswallet.balance > 0(plus org member quota forMEMBER).CallSessionexists 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+CallSettlementreconciliation. - Provisional estimated debit when Twilio
priceis missing oncompleted; cost sync jobs and later callbacks finalize pricing.
Settlement and retry policy
Primary settlement:
- Terminal
call-status→upsertCallAndSettleWallet(with advisory lock). Ifcompletedand Twilio price present → retail debit +CallSettlement. If price missing → estimated debit +settled_estimated, then delta when price arrives.
Fallback settlement:
- If
completedcall has no Twilio price yet, enqueueCallCostSyncJob. - Retry attempts with backoff; stop as
completed(priced) orfailed(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.ts — Transaction.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
completedcalls 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/refundcreates the Stripe Refund;refund.created/charge.refundedwebhooks debit the wallet idempotently (Stripe card refunds (super-admin API + webhooks)). Refunds created only in the Stripe Dashboard still emit webhooks — ensurePOST /api/webhooks/stripeis 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-
falsevalue → Numbers page enabled
NEXT_PUBLIC_ENABLE_NUMBERS_PAGE- Client mirror exposed via
next.config.jsfromENABLE_NUMBERS_PAGE. - Used by client navigation/components (Sidebar/UserMenu/SMS CTA/Settings link routing).
- Typically not set manually; generated from server flag.
- Client mirror exposed via
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
- Used by: Twilio REST client + Pricing API (
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)
- Used by: webhook validation + REST + Pricing API (
TWILIO_API_KEY,TWILIO_API_SECRET- Used by: Twilio Voice SDK access token generation (
lib/twilio.ts)
- Used by: Twilio Voice SDK access token generation (
TWILIO_TWIML_APP_SID- Used by: VoiceGrant outgoingApplicationSid (
lib/twilio.ts)
- Used by: VoiceGrant outgoingApplicationSid (
TWILIO_PHONE_NUMBER- Used by: outbound callerId and as originationPhone for pricing decisions (
app/api/twilio/voice/outbound/route.ts)
- Used by: outbound callerId and as originationPhone for pricing decisions (
ENABLE_TWILIO_TEST_MODE- When
true: Twilio virtual-number provisioning/search uses test credentials (lib/twilio/virtual-number-client.ts);searchAvailableNumbersreturns fake inventory with local filter simulation (lib/twilio/virtual-number-service.ts). RequiresTWILIO_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 TwilioIncomingPhoneNumberresources). - When unset/false: live Twilio Available Phone Numbers API for search and purchase.
- When
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
- status callback:
- Used by:
lib/twilio.tsfor webhook validation expected URL (prefersSERVER_URL).
- Used to construct absolute callback URLs for Twilio:
Billing / wallet
RINGVOO_VOICE_RETAIL_MULTIPLIER- Used by:
lib/billing/voice-pricing.ts - Default:
2
- Used by:
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 whenwallet.balance ≤ lastPurchaseAmount × threshold.
- Used by:
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.
- Used by:
RINGVOO_VOICE_MAX_CALL_SECONDS(optional)- Max
<Dial timeLimit>cap in seconds (parsed inlib/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.
- Max
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)
- Used by:
RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_URL(optional)- Used by: inbound TwiML
<Play>when set to anhttp(s)URL
- Used by: inbound TwiML
RINGVOO_INBOUND_LOW_BALANCE_CALLER_AUDIO_PATH(optional)- Used with
SERVER_URLto build<Play>audio as${SERVER_URL}${path}when no absolute override is set - Default path if unset:
/sounds/ringvoo-inbound-caller-unavailable.wav
- Used with
RINGVOO_INBOUND_VOICE_PRICE_SOURCE(optional)csv(default when unset or any value other thantwilio): inbound completed settlement uses CSV longest-prefix (or country floor) and does not use Twilio’s browser-legPricefor retail; aligns with table pricing (lib/call-billing.ts,isInboundVoiceBillingFromTwilioWebhook).twilio: inbound settlement uses Twilio webhook/REST carrierPrice×RINGVOO_VOICE_RETAIL_MULTIPLIERwhen present (similar to outbound).
TWILIO_LOG_BILLING(optional)- When
1/true: verbose[CallBilling]logs inlib/call-billing.ts(duration source selection, etc.).
- When
SIGNUP_BONUS_USD_PROD,SIGNUP_BONUS_USD_DEV- Used by:
lib/wallet.ts(signup bonus) - Defaults if missing/invalid: prod
$0.25, dev$50
- Used by:
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 (defaultUSD).SMS_ALLOW_NEGATIVE_BALANCE,SMS_NEGATIVE_BALANCE_LIMIT— inbound wallet debit policy (lib/wallet.tsdebitWalletInboundSms).- 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).
- Used by: NextAuth, and by email links in
NEXTAUTH_SECRET- Used by: NextAuth and middleware (
lib/auth.ts,middleware.ts)
- Used by: NextAuth and middleware (
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET- Optional, used by:
lib/auth.tsto enable Google OAuth provider.
- Optional, used by:
Email (Resend)
RESEND_API_KEY- Used by:
lib/email.ts - If missing: emails are disabled (warns).
- Used by:
TRANSACTIONAL_FROM_EMAIL,TRANSACTIONAL_REPLY_TO_EMAIL- Used by: verification + password-reset emails in
lib/email.ts.
- Used by: verification + password-reset emails in
SUPPORT_FROM_EMAIL,SUPPORT_INBOX_EMAIL- Used by: support/contact submissions (
sendSupportEmailinlib/email.ts,POST /api/support). SUPPORT_INBOX_EMAILis also used on PDF invoices from/dashboard/billing(app/api/billing/invoice/route.ts→lib/billing/invoice-pdf.ts; falls back tosupport@ringvoo.comif unset).- Current format: intentionally simple/plain internal email (not the styled alert shell).
- Used by: support/contact submissions (
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 toinfo@ringvoo.com.
- Used by: inbound SMS and missed-call user notifications in
- Manual top-up reminder email (super-admin action):
- Triggered by:
POST /api/admin/users/[id]/topup-reminderand admin UI actions (/admin/users,/admin/sms-risk). - Uses transactional sender/reply-to settings from
lib/email.ts(sendAdminTopupReminderEmail).
- Triggered by:
Current alert behavior
- Inbound SMS: when the server receives a valid inbound SMS (live webhook or later
sync-smsrecovery 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-jobsauthorization (app/api/twilio/sync-cost-jobs/route.ts)/api/twilio/refresh-voice-pricing-csvauthorization (app/api/twilio/refresh-voice-pricing-csv/route.ts)/api/twilio/call-holds/cleanupauthorization (app/api/twilio/call-holds/cleanup/route.ts)/api/twilio/sync-callsauthorization (app/api/twilio/sync-calls/route.ts)/api/twilio/sync-smsauthorization (app/api/twilio/sync-sms/route.ts)
- Bearer cron routes
sync-cost-jobsandrefresh-voice-pricing-csv: undernext dev(NODE_ENV=development), bearer is optional even whenCRON_SECRETis set (lib/cron-bearer-auth.ts). In production (next start/ Vercel), bearer must match whenCRON_SECRETis set.
- Used by:
TWILIO_SYNC_CALLS_SECRET(optional)- Used by:
/api/twilio/sync-callsas an additional bearer auth option.
- Used by:
TWILIO_SYNC_SMS_SECRET(optional)- Used by:
/api/twilio/sync-smsas an additional bearer auth option.
- Used by:
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.
- When
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
- Comma-separated:
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
- Comma-separated levels:
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(min500, max15000)
- Used by:
NEXT_PUBLIC_DIALER_RINGBACK_AUDIO_SRC(optional)- Used by:
lib/dialer-call-progress-audio.ts - Default:
/sounds/ringvoo-ringback-cc0.mp3
- Used by:
NEXT_PUBLIC_DIALER_CONNECT_MESSAGE_AUDIO_SRC(optional)- Used by:
lib/dialer-call-progress-audio.ts - Default:
/sounds/ringvoo-connect-message.wav
- Used by:
NEXT_PUBLIC_DIALER_CONNECT_PROMPT_TEXT(optional)- Used by:
lib/dialer-call-progress-audio.ts(Web Speech fallback text)
- Used by:
NEXT_PUBLIC_DIALER_HANGUP_TONE_AUDIO_SRC(optional)- Used by:
lib/dialer-call-progress-audio.ts - Default:
/sounds/ringvoo-end-call.mp3
- Used by:
NEXT_PUBLIC_INCOMING_RINGTONE_AUDIO_SRC(optional)- Used by:
lib/incoming-ringtone.ts - Default:
/sounds/ringvoo-incoming.mp3
- Used by:
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
- Used by:
NEXT_PUBLIC_DIALER_LOW_BALANCE_BLOCK_MESSAGE/NEXT_PUBLIC_DIALER_LOW_BALANCE_MEMBER_BLOCK_MESSAGE(optional)- Used by:
components/marketing/DialerPreview.tsx(toast copy)
- Used by:
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.”
- Used by:
NEXT_PUBLIC_DIALER_CALL_NOT_COMPLETED_AUDIO_SRC(optional)- Used by:
components/marketing/DialerPreview.tsx - Default:
/sounds/ringvoo-call-not-completed.wav
- Used by:
NEXT_PUBLIC_DEMO_VIDEO_URL/NEXT_PUBLIC_DEMO_VIDEO_SRC- Used by:
components/marketing/VideoSection.tsx - Purpose: configure marketing demo video source.
- Used by:
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)
- Required by Prisma datasource (
DIRECT_DATABASE_URL- Present in
.env.examplefor migration workflows (not referenced directly in Prisma schema here).
- Present in
- Deploy: after pulling migrations (e.g.
20260411210000_organization_stripe_pm_taxfor org PM + tax fields;20260411230000_drop_team_team_memberto drop legacyTeam/TeamMember;20260414120000_voice_call_session_settlementforCallSession/CallSettlement/Wallet.lastPurchaseAmountand dropsCallHold), runnpx prisma migrate deployin each environment. On Windows,npx prisma generatecan hit EPERM ifnpm run devlocks 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 listenwhen testing locally. - Missing →
/api/webhooks/stripereturns500(misconfigured).
- Signing secret from Stripe Dashboard (webhook endpoint) or from
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY(optional)- Publishable key (
pk_…); not required for redirect-only Checkout but useful for future Elements or client-side checks.
- Publishable key (
STRIPE_CHECKOUT_ADAPTIVE_PRICING(optional)- When
true/1/yes: enable Stripe Adaptive Pricing on Checkout (adaptive_pricing.enabled). - Otherwise (unset,
false, etc.): off —lib/stripe/checkout-adaptive-pricing.tspassesenabled: falseon wallet, virtual-number subscription, and setup Checkout sessions.
- When
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 Coupon id (
STRIPE_VIRTUAL_NUMBER_PRICE_ID- Recurring Price id (
price_…) used as the pricing template for virtual number subscription Checkout. The appprices.retrievethis id and builds inlineprice_data+product_dataso 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.
- Recurring Price id (
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
.gitignoreexisted, or - your git status snapshot is old, or
- there are nested
.gitignorerules (unlikely here).
“Insufficient balance” vs back-to-back settlement / legacy PENDING_HOLD_SETTLEMENT
- Insufficient:
evaluateVoiceCallLiquidityfails becausewallet.balance ≤ 0(see Voice admission). Separately,GET /api/twilio/voice/max-duration-previewmay returninsufficient: truewhen 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 aCallHoldrace 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). WithCallHoldremoved, 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-smscan 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/stripeis reachable from the internet (e.g. ngrok HTTPS URL) andSTRIPE_WEBHOOK_SECRETmatches the signing secret for that endpoint in the Stripe Dashboard. - In Stripe → Developers → Webhooks → your endpoint, Recent deliveries should show
200.400on 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, … — seelib/stripe/webhook-handlers.ts).checkout.session.completedalso drivesmode: setup(save-card-only) sessions — no wallet row is created for those. NEXTAUTH_URL/ app base URL used in Checkoutsuccess_url/cancel_urlshould match how users access the app (lib/stripe/client.tsgetAppBaseUrl()prefersNEXTAUTH_URLthenSERVER_URL).
Delayed Twilio price
If completed calls are not being billed:
- confirm
call-statusis receiving callbacks (requiresSERVER_URL) - check
CallCostSyncJobrows; run/api/twilio/sync-cost-jobs?limit=50 - confirm
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENare set (REST fetch requires them)
Important nuance:
- not every eventually-settled recovery call will create a
CallCostSyncJobrow - 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 > 0withscanned = 0and no new job row, yet the call /CallSettlement/ wallet still settle correctly
Dialer UX behavior reference
Source files for this section:
components/marketing/DialerPreview.tsxcomponents/dashboard/TwilioProvider.tsxcomponents/marketing/Toast.tsxlib/dialer-call-progress-audio.ts(outbound local ringback + connect line + hangup tone)lib/incoming-ringtone.ts(inbound ringtone URL + unlock)
Modal matrix
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
- state:
- Add contact modal
- state:
contactModalOpen - behavior: local UI helper, fills number into dialer
- state:
- 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)
- state:
- Buy phone number modal
- state:
buyNumberModalOpen - behavior: currently UI preview flow for future number-purchase backend
- state:
- 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-previewreturnsinsufficient: 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
finalizingModalOpenstate 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_MSbounds 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, errore, infoi
Current high-signal toasts include:
Enter a number to callGo online first to make a callFinalizing previous call... Starting your next call shortly.(configurable viaNEXT_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
Callrows, 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:
8000ms (range2000..15000) - current project value:
6000in.envand.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
- purpose: optional gate-path reconcile in
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):
unlockDialerCallProgressAudio()+startDialerCallProgressAudio()run on Dial in the same user gesture and beforeawait getUserMedia, so the browser does not blockplay()/ speech later.- 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. - 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”). - 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 earlierandroid_end_call_tone.mp3for 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:
wantsOnlineReftracks 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
- User opens Numbers page or buy-number modal.
- App calls
GET /api/numbers/search. - User selects a number and starts checkout (
POST /api/numbers/create-checkout). - Stripe subscription checkout completes.
- Webhook provisions Twilio incoming number and writes DB rows:
TwilioNumberVirtualNumberVirtualNumberOrderstatus/attempt rows
- Number appears in dashboard selection (
phonecall-from mode).
Subscription billing (quick reference)
- Failed monthly charge → Stripe webhooks →
VirtualNumber.status = past_due; Settings shows a payment warning; app still treatspast_duelikeactivefor 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.tsxapp/dashboard/numbers/page.tsxapp/api/numbers/search/route.tsapp/api/numbers/create-checkout/route.tsapp/api/numbers/retry-provisioning/route.tslib/twilio/virtual-number-service.tslib/stripe/virtual-number-checkout.tslib/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.twilioSidlikePNTEST... - 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.twilioSidshould be realPN...(notPNTEST...)- caller-id presentation tests are meaningful in this mode
Fast validation checklist
.envhasENABLE_TWILIO_TEST_MODE=falsefor live behavior testsTWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKENmatch the account owning the numberVirtualNumber.phoneNumberandTwilioNumber.phoneNumberare E.164 (+1...)VirtualNumber.twilioSidis realPN...VirtualNumber.statusisactive(or eligible status per policy)
Caller-id behavior (virtual number)
- When user selects an owned virtual number (
phonemode), 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.
- row may be test-provisioned (
- Works in UI but not in live caller-id:
- verify real Twilio ownership and matching
TWILIO_ACCOUNT_SID.
- verify real Twilio ownership and matching
- Retry provisioning loop:
- inspect latest
VirtualNumberProvisionAttemptfor Twilio code/message.
- inspect latest
Future roadmap anchors
When adding new features, update this doc in these places:
- SMS — keep SMS (Twilio Messaging), SMS environment variables, and
.env.examplealigned withlib/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
Organizationrequire a migration +prisma migrate deployin each environment; document under Data model (Prisma). - Buy number
- Extend
TwilioNumberownership rules and inbound routing assumptions. - Document lifecycle: purchase → assign to user/team → configure voice webhook URL.
- Extend
- Enterprise admin panel
- Super-admin-only routes under
/admin/**(e.g./admin/billing,POST /api/admin/payments/refund) — document RBAC (UserRole.SUPER_ADMIN),Payment/Transactionexpectations, and that Stripe refunds must go through webhooks for wallet alignment. - Broader notes on RBAC (
UserRole) and multi-tenant constraints (ContextScope, org wallet,OrgMemberRole).
- Super-admin-only routes under
- Stripe org lifecycle (optional later) — deleting an
Organizationin-app does not delete the Stripe Customer; add Dashboard cleanup orcustomers.delif you need strict GDPR/provider hygiene.