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