Enquiry Billing Model

Status: Draft Owner: @bilal Last Updated: 2026-03-09

ADR: ADR-020 Enquiry Billing Model Parent: Commercial Tiers Scoping


1. Problem Statement

The Acquisition commercial motion prices customers per enquiry. We need a precise, auditable, contractual definition of what constitutes a billable enquiry — and the current codebase doesn’t have one.


2. How Conversations Work Today

2.1 Data Model

Conversation
  id                UUID
  organisationId    FK -> organisations
  tenantId          FK -> tenants (nullable)
  channel           WHATSAPP | VOICE | CHAT | EMAIL | SMS
  status            ACTIVE | RESOLVED | ESCALATED | ARCHIVED
  identityStatus    UNIDENTIFIED | IDENTIFIED | CONFIRMED | ACTIVE
  gatheringState    IDLE | GATHERING_DETAILS | AWAITING_PHOTO | ISSUE_CREATED
  issueId           FK -> issues (unique, nullable) -- max 1 issue per conversation
  handledByAI       boolean
  escalatedToHuman  boolean
  startedAt         timestamp
  endedAt           timestamp (only set for voice currently)
  summary           text (auto-generated)

Message
  id                UUID
  conversationId    FK -> conversations
  direction         INBOUND | OUTBOUND
  role              TENANT | AI | STAFF
  content           text
  mediaUrl          text (photos/documents)
  sentAt            timestamp

2.2 Inbound Flow

Tenant sends message (SMS / WhatsApp / Voice / Chat / Email)
  |
Identify tenant (phone/email lookup)
  |- Match found -> CONFIRMED / IDENTIFIED
  |- No match -> UNIDENTIFIED (AI asks for identity)
  |
Get or create conversation
  |- Existing ACTIVE conversation for same phone+channel within 24h -> reuse
  |- Otherwise -> create new conversation
  |
Store inbound message
  |
AI orchestration (Claude tool-use)
  |- ask_for_details  -> gatheringState = GATHERING_DETAILS
  |- ask_for_photo    -> gatheringState = AWAITING_PHOTO
  |- create_issue     -> gatheringState = ISSUE_CREATED, issue linked
  |- respond          -> general Q&A, no state change
  |- escalate         -> status = ESCALATED
  |
Store outbound message -> send to tenant via channel

2.3 Conversation Lifecycle Gaps

ChannelHow conversation startsHow conversation endsGap
VoiceCall beginsCall-end webhook → RESOLVED, endedAt setClean
WhatsAppFirst messageNever explicitly. 24h reuse window, then new conversation createdNo resolution event
SMSFirst messageSame as WhatsAppNo resolution event
ChatFrontend creates sessionNo explicit endNo resolution event
EmailNot yet implementedN/AN/A

Key problem: For text-based channels, conversations stay ACTIVE forever. There is no auto-close, no timeout, no explicit resolution signal from the AI.


3. Edge Cases That Break Naive Billing

ScenarioConversationsIssues”1 conv = 1 enquiry""1 issue = 1 enquiry”
Tenant texts about a leak, issue created111 (correct)1 (correct)
Tenant texts “actually the tap too” 3h laterStill 1 (24h reuse)Still 1 (can’t create 2nd)1 (under-counted?)1 (under-counted?)
Tenant texts next day, new problem222 (correct)2 (correct)
Tenant texts just to ask a question101 (over-counted?)0 (we ate the cost)
Tenant sends “hello” then never responds101 (over-counted)0 (fair)
Voice call, issue created, resolved111 (correct)1 (correct)
Escalation to human, no issue101 (fair)0 (we ate the cost)
Spam / wrong number101 (unfair to customer)0 (fair)
Tenant identified but can’t confirm property101 (unfair)0 (fair)

4. Billing Model Options

Option A: Conversation = Enquiry (simplest)

Every unique conversation.id where status IN (RESOLVED, ESCALATED) = 1 billable enquiry.

ProsCons
Simple to implement and audit24h boundary is arbitrary
Every conversation consumes resourcesQ&A and spam count as billable
Easy to explainCustomer disputes on non-issue conversations

Option B: Issue = Enquiry (most intuitive)

Only conversations that create an issue are billable.

ProsCons
Easy for customers to understandWe absorb cost of Q&A, escalations, spam
No disputes on non-issue conversationsUnder-counts actual resource usage
Maps to existing issues table”2 problems in 1 conversation” = 1 enquiry

Option C: Resolved Conversation = Enquiry (most accurate)

Add explicit resolution events. Only conversations marked RESOLVED with a meaningful outcome are billable.

ProsCons
Most precise billingRequires code changes to tenant engine
Handles multi-turn conversations cleanlyNeed timeout fallback if AI never resolves
Excludes spam, abandoned, failed identificationMore complex to audit

Start with Option A mechanics, build toward Option C precision.

Phase 1: Foundation (part of Phase 0)

ALTER TABLE conversations ADD COLUMN billable BOOLEAN DEFAULT true;
ALTER TABLE conversations ADD COLUMN billing_category VARCHAR(30);
  -- 'issue_created', 'escalation', 'q_and_a', 'abandoned', 'spam', 'identity_failed'
ALTER TABLE conversations ADD COLUMN resolved_at TIMESTAMPTZ;
  • Auto-resolution job: Background task marks ACTIVE conversations as RESOLVED after configurable inactivity timeout:
    • Voice: immediate (already handled by call-end webhook)
    • WhatsApp/SMS/Chat: 2 hours of no messages (configurable per org)
    • Sets endedAt and resolved_at
  • Billing category assignment: When resolving, categorise:
    • issue_created — conversation created an issue
    • escalation — conversation was escalated to human
    • q_and_a — conversation resolved without issue or escalation
    • abandoned — tenant stopped responding before identity confirmed
    • spam — detected as spam (future: AI classification)
    • identity_failed — tenant couldn’t be identified/confirmed
  • Billable flag: Default true. Set false for abandoned, spam, identity_failed, and internal test conversations.

Phase 2: Enquiry Tracking (part of Acquisition MVP)

CREATE TABLE enquiries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organisation_id UUID NOT NULL REFERENCES organisations(id),
    conversation_id UUID NOT NULL REFERENCES conversations(id),
    issue_id UUID REFERENCES issues(id),
    channel VARCHAR(30) NOT NULL,
    billing_category VARCHAR(30) NOT NULL,
    billable BOOLEAN NOT NULL DEFAULT true,
    billing_period_start DATE NOT NULL,
    billing_period_end DATE NOT NULL,
    billed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE INDEX idx_enquiries_billing
  ON enquiries (organisation_id, billing_period_start, billable);
  • Enquiry creation: When a conversation resolves, create an enquiry record if billable = true
  • Monthly aggregation for invoicing
  • Invoice line item: {billable_count} x {price_per_enquiry} = total

Phase 3: AI Resolution Signal (future)

  • Add resolve_conversation as an AI tool — explicit, intent-aware resolution
  • Fallback: auto-resolution timeout still runs for conversations the AI doesn’t explicitly close

6. Contractual Definition

Recommended wording:

An Enquiry is a single tenant interaction, initiated by the tenant via any supported channel (SMS, WhatsApp, Voice, Email, or Web Chat), that is processed by the Envo platform and results in a meaningful outcome: a maintenance issue being raised, a question being answered, or the interaction being escalated to a human operator.

The following are excluded from billable enquiries:

  • Interactions where the tenant could not be identified and abandoned the conversation
  • Spam or misdirected messages
  • System-initiated messages (reminders, status updates)

Multiple messages within the same interaction (e.g., a back-and-forth to gather issue details) constitute a single enquiry. A new enquiry begins when a tenant initiates contact after the prior interaction has been resolved or after a period of inactivity (default: 2 hours).


7. Metrics Dashboard (envo-admin)

For Acquisition customers, envo-admin should show:

  • Total enquiries this period (billable + non-billable)
  • Billable enquiries (with breakdown by category)
  • Estimated invoice amount (billable x price_per_enquiry, floored to minimum_monthly)
  • Channel breakdown (WhatsApp vs Voice vs SMS vs Chat)
  • Trend (month-over-month)
  • Disputed enquiries (future: customer can flag enquiries for review)

8. Open Questions

  1. Inactivity timeout default — 2 hours feels right for text channels. Should this be configurable per org?
  2. Q&A billability — Should pure Q&A conversations be billable? They consume resources but the customer might argue “nothing happened”.
  3. Multi-problem conversations — Unique constraint on conversations.issue_id means 1 conversation = max 1 issue. If a tenant reports 2 problems, is that 1 enquiry or 2?
  4. Dispute resolution — Manual review in envo-admin? Credit note?
  5. Free tier allowance — Should Acquisition customers get N free enquiries per month?

9. Key Files

ComponentPath
Conversation CRUDenvo-dashboard/lib/tenant-engine/conversation.ts
Message processingenvo-dashboard/lib/tenant-engine/process.ts
Tenant identificationenvo-dashboard/lib/tenant-engine/identify.ts
Issue auto-creationenvo-dashboard/lib/tenant-engine/issue-creation.ts
Inbound webhooksenvo-dashboard/app/api/tenant/webhooks/{whatsapp,voice}/route.ts
Prisma schemaenvo-dashboard/prisma/schema.prisma
Conversation orchestration docsdocs/01-Architecture/Conversation Orchestration.md
Issue lifecycle docsdocs/05-Specs/Flows/Issue Lifecycle.md