From 9e56a024c5b21074c6aacd5eee4dc8e610ca1915 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Thu, 7 May 2026 20:11:17 +0700 Subject: [PATCH] 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. --- .../services/whatsapp/incoming_call_service.rb | 16 ++++++++++++++-- .../whatsapp/incoming_call_service_spec.rb | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb index 772732e89..d290e96c2 100644 --- a/enterprise/app/services/whatsapp/incoming_call_service.rb +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -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?) diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb index d52adc041..53c7974f4 100644 --- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb @@ -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)