fix(voice): honor Meta failure reasons on terminate

Call::TERMINAL_STATUSES include 'failed' but handle_terminate only ever
chose between 'completed' and 'no_answer'. A network drop or any other
failure reason landing after status=ACCEPTED was being recorded as
'completed' purely because the call was already in_progress, surfacing
failed conversations as successful in the dashboard. Map known failure
reasons to 'failed' before falling back to the answered/no_answer
heuristic.
This commit is contained in:
Tanmay Deep Sharma
2026-05-07 20:11:17 +07:00
parent 7a7de71c39
commit 9e56a024c5
2 changed files with 23 additions and 2 deletions
@@ -120,13 +120,25 @@ class Whatsapp::IncomingCallService
next if call.terminal?
duration = payload[:duration]&.to_i
status = answered?(call, duration) ? 'completed' : 'no_answer'
reason = payload[:terminate_reason].to_s
status = derive_terminate_status(call, duration, reason)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
update_call!(call, status, duration_seconds: duration, end_reason: payload[:terminate_reason], meta: meta)
update_call!(call, status, duration_seconds: duration, end_reason: reason, meta: meta)
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
end
end
# Provider-reported failures trump the answered/no_answer heuristic. An
# in_progress call that Meta later terminates with a failure reason would
# otherwise be recorded as 'completed' purely because it had been accepted.
FAILURE_REASONS = %w[failed error rejected busy invalid_offer cancelled].freeze
def derive_terminate_status(call, duration, reason)
return 'failed' if FAILURE_REASONS.any? { |r| reason.include?(r) }
answered?(call, duration) ? 'completed' : 'no_answer'
end
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
@@ -104,6 +104,15 @@ describe Whatsapp::IncomingCallService do
expect(call.reload.status).to eq('no_answer')
end
it 'records the call as failed when Meta reports a failure reason for an in_progress call' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'terminate', duration: 12, terminate_reason: 'failed')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'failed', duration_seconds: 12, end_reason: 'failed')
end
it 'is a no-op when the call is already terminal so retries cannot flip a completed call to no_answer' do
call.update!(status: 'completed', duration_seconds: 5, direction: :outgoing, accepted_by_agent: nil)
allow(ActionCable.server).to receive(:broadcast)