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
| Channel | How conversation starts | How conversation ends | Gap |
|---|---|---|---|
| Voice | Call begins | Call-end webhook → RESOLVED, endedAt set | Clean |
| First message | Never explicitly. 24h reuse window, then new conversation created | No resolution event | |
| SMS | First message | Same as WhatsApp | No resolution event |
| Chat | Frontend creates session | No explicit end | No resolution event |
| Not yet implemented | N/A | N/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
| Scenario | Conversations | Issues | ”1 conv = 1 enquiry" | "1 issue = 1 enquiry” |
|---|---|---|---|---|
| Tenant texts about a leak, issue created | 1 | 1 | 1 (correct) | 1 (correct) |
| Tenant texts “actually the tap too” 3h later | Still 1 (24h reuse) | Still 1 (can’t create 2nd) | 1 (under-counted?) | 1 (under-counted?) |
| Tenant texts next day, new problem | 2 | 2 | 2 (correct) | 2 (correct) |
| Tenant texts just to ask a question | 1 | 0 | 1 (over-counted?) | 0 (we ate the cost) |
| Tenant sends “hello” then never responds | 1 | 0 | 1 (over-counted) | 0 (fair) |
| Voice call, issue created, resolved | 1 | 1 | 1 (correct) | 1 (correct) |
| Escalation to human, no issue | 1 | 0 | 1 (fair) | 0 (we ate the cost) |
| Spam / wrong number | 1 | 0 | 1 (unfair to customer) | 0 (fair) |
| Tenant identified but can’t confirm property | 1 | 0 | 1 (unfair) | 0 (fair) |
4. Billing Model Options
Option A: Conversation = Enquiry (simplest)
Every unique conversation.id where status IN (RESOLVED, ESCALATED) = 1 billable enquiry.
| Pros | Cons |
|---|---|
| Simple to implement and audit | 24h boundary is arbitrary |
| Every conversation consumes resources | Q&A and spam count as billable |
| Easy to explain | Customer disputes on non-issue conversations |
Option B: Issue = Enquiry (most intuitive)
Only conversations that create an issue are billable.
| Pros | Cons |
|---|---|
| Easy for customers to understand | We absorb cost of Q&A, escalations, spam |
| No disputes on non-issue conversations | Under-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.
| Pros | Cons |
|---|---|
| Most precise billing | Requires code changes to tenant engine |
| Handles multi-turn conversations cleanly | Need timeout fallback if AI never resolves |
| Excludes spam, abandoned, failed identification | More complex to audit |
5. Recommended Approach: Hybrid (A + C)
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
endedAtandresolved_at
- Billing category assignment: When resolving, categorise:
issue_created— conversation created an issueescalation— conversation was escalated to humanq_and_a— conversation resolved without issue or escalationabandoned— tenant stopped responding before identity confirmedspam— detected as spam (future: AI classification)identity_failed— tenant couldn’t be identified/confirmed
- Billable flag: Default
true. Setfalsefor 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
enquiryrecord ifbillable = true - Monthly aggregation for invoicing
- Invoice line item:
{billable_count} x {price_per_enquiry} = total
Phase 3: AI Resolution Signal (future)
- Add
resolve_conversationas 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
- Inactivity timeout default — 2 hours feels right for text channels. Should this be configurable per org?
- Q&A billability — Should pure Q&A conversations be billable? They consume resources but the customer might argue “nothing happened”.
- Multi-problem conversations — Unique constraint on
conversations.issue_idmeans 1 conversation = max 1 issue. If a tenant reports 2 problems, is that 1 enquiry or 2? - Dispute resolution — Manual review in
envo-admin? Credit note? - Free tier allowance — Should Acquisition customers get N free enquiries per month?
9. Key Files
| Component | Path |
|---|---|
| Conversation CRUD | envo-dashboard/lib/tenant-engine/conversation.ts |
| Message processing | envo-dashboard/lib/tenant-engine/process.ts |
| Tenant identification | envo-dashboard/lib/tenant-engine/identify.ts |
| Issue auto-creation | envo-dashboard/lib/tenant-engine/issue-creation.ts |
| Inbound webhooks | envo-dashboard/app/api/tenant/webhooks/{whatsapp,voice}/route.ts |
| Prisma schema | envo-dashboard/prisma/schema.prisma |
| Conversation orchestration docs | docs/01-Architecture/Conversation Orchestration.md |
| Issue lifecycle docs | docs/05-Specs/Flows/Issue Lifecycle.md |