-
-
-
+
+
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
+
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+ {{ provider.label }}
+
+
+ {{ provider.description }}
+
+
+
+
-
-
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
new file mode 100644
index 000000000..070957717
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -0,0 +1,327 @@
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
+
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
+
+
+
+
+
+
+ {{ benefit.text }}
+
+
+
+
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index aef95d1b1..68f4780e9 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -5,6 +5,7 @@ import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
+import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
@@ -198,6 +199,19 @@ export const actions = {
throw new Error(error);
}
},
+ createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
+ try {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
+ const response = await WhatsappChannel.createEmbeddedSignup(params);
+ commit(types.default.ADD_INBOXES, response.data);
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ sendAnalyticsEvent('whatsapp');
+ return response.data;
+ } catch (error) {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ throw error;
+ }
+ },
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 1848e17a9..b7bfd0da7 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
validate :validate_provider_config
after_create :sync_templates
+ after_create_commit :setup_webhooks
def name
'Whatsapp'
@@ -67,4 +68,41 @@ class Channel::Whatsapp < ApplicationRecord
def validate_provider_config
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
+
+ def setup_webhooks
+ return unless should_setup_webhooks?
+
+ perform_webhook_setup
+ rescue StandardError => e
+ handle_webhook_setup_error(e)
+ end
+
+ def should_setup_webhooks?
+ whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
+ end
+
+ def whatsapp_cloud_provider?
+ provider == 'whatsapp_cloud'
+ end
+
+ def embedded_signup_source?
+ provider_config['source'] == 'embedded_signup'
+ end
+
+ def webhook_config_present?
+ provider_config['business_account_id'].present? && provider_config['api_key'].present?
+ end
+
+ def perform_webhook_setup
+ business_account_id = provider_config['business_account_id']
+ api_key = provider_config['api_key']
+
+ Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
+ end
+
+ def handle_webhook_setup_error(error)
+ Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
+ # Don't raise the error to prevent channel creation from failing
+ # Webhooks can be retried later
+ end
end
diff --git a/app/presenters/message_content_presenter.rb b/app/presenters/message_content_presenter.rb
index 461dc8b88..016e5caea 100644
--- a/app/presenters/message_content_presenter.rb
+++ b/app/presenters/message_content_presenter.rb
@@ -17,4 +17,4 @@ class MessageContentPresenter < SimpleDelegator
def survey_url(conversation_uuid)
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation_uuid}"
end
-end
\ No newline at end of file
+end
diff --git a/app/services/whatsapp/channel_creation_service.rb b/app/services/whatsapp/channel_creation_service.rb
new file mode 100644
index 000000000..3039ca003
--- /dev/null
+++ b/app/services/whatsapp/channel_creation_service.rb
@@ -0,0 +1,75 @@
+class Whatsapp::ChannelCreationService
+ def initialize(account, waba_info, phone_info, access_token)
+ @account = account
+ @waba_info = waba_info
+ @phone_info = phone_info
+ @access_token = access_token
+ end
+
+ def perform
+ validate_parameters!
+
+ existing_channel = find_existing_channel
+ raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
+
+ create_channel_with_inbox
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'Account is required' if @account.blank?
+ raise ArgumentError, 'WABA info is required' if @waba_info.blank?
+ raise ArgumentError, 'Phone info is required' if @phone_info.blank?
+ raise ArgumentError, 'Access token is required' if @access_token.blank?
+ end
+
+ def find_existing_channel
+ Channel::Whatsapp.find_by(
+ account: @account,
+ phone_number: @phone_info[:phone_number]
+ )
+ end
+
+ def create_channel_with_inbox
+ ActiveRecord::Base.transaction do
+ channel = create_channel
+ create_inbox(channel)
+ channel.reload
+ channel
+ end
+ end
+
+ def create_channel
+ Channel::Whatsapp.create!(
+ account: @account,
+ phone_number: @phone_info[:phone_number],
+ provider: 'whatsapp_cloud',
+ provider_config: build_provider_config
+ )
+ end
+
+ def build_provider_config
+ {
+ api_key: @access_token,
+ phone_number_id: @phone_info[:phone_number_id],
+ business_account_id: @waba_info[:waba_id],
+ source: 'embedded_signup'
+ }
+ end
+
+ def create_inbox(channel)
+ inbox_name = build_inbox_name
+
+ Inbox.create!(
+ account: @account,
+ name: inbox_name,
+ channel: channel
+ )
+ end
+
+ def build_inbox_name
+ business_name = @phone_info[:business_name] || @waba_info[:business_name]
+ "#{business_name} WhatsApp"
+ end
+end
diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb
new file mode 100644
index 000000000..0adf073cd
--- /dev/null
+++ b/app/services/whatsapp/embedded_signup_service.rb
@@ -0,0 +1,44 @@
+class Whatsapp::EmbeddedSignupService
+ def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
+ @account = account
+ @code = code
+ @business_id = business_id
+ @waba_id = waba_id
+ @phone_number_id = phone_number_id
+ end
+
+ def perform
+ validate_parameters!
+
+ # Exchange code for user access token
+ access_token = Whatsapp::TokenExchangeService.new(@code).perform
+
+ # Fetch phone information
+ phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
+
+ # Validate token has access to the WABA
+ Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
+
+ # Create channel
+ waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
+
+ # Webhook setup is now handled in the channel after_create_commit callback
+ Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
+ rescue StandardError => e
+ Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
+ raise e
+ end
+
+ private
+
+ def validate_parameters!
+ missing_params = []
+ missing_params << 'code' if @code.blank?
+ missing_params << 'business_id' if @business_id.blank?
+ missing_params << 'waba_id' if @waba_id.blank?
+
+ return if missing_params.empty?
+
+ raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
+ end
+end
diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb
new file mode 100644
index 000000000..1aebdad2a
--- /dev/null
+++ b/app/services/whatsapp/facebook_api_client.rb
@@ -0,0 +1,86 @@
+class Whatsapp::FacebookApiClient
+ BASE_URI = 'https://graph.facebook.com'.freeze
+
+ def initialize(access_token = nil)
+ @access_token = access_token
+ @api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
+ end
+
+ def exchange_code_for_token(code)
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/oauth/access_token",
+ query: {
+ client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
+ client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
+ code: code
+ }
+ )
+
+ handle_response(response, 'Token exchange failed')
+ end
+
+ def fetch_phone_numbers(waba_id)
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
+ query: { access_token: @access_token }
+ )
+
+ handle_response(response, 'WABA phone numbers fetch failed')
+ end
+
+ def debug_token(input_token)
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/debug_token",
+ query: {
+ input_token: input_token,
+ access_token: build_app_access_token
+ }
+ )
+
+ handle_response(response, 'Token validation failed')
+ end
+
+ def register_phone_number(phone_number_id, pin)
+ response = HTTParty.post(
+ "#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
+ headers: request_headers,
+ body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
+ )
+
+ handle_response(response, 'Phone registration failed')
+ end
+
+ def subscribe_waba_webhook(waba_id, callback_url, verify_token)
+ response = HTTParty.post(
+ "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
+ headers: request_headers,
+ body: {
+ override_callback_uri: callback_url,
+ verify_token: verify_token
+ }.to_json
+ )
+
+ handle_response(response, 'Webhook subscription failed')
+ end
+
+ private
+
+ def request_headers
+ {
+ 'Authorization' => "Bearer #{@access_token}",
+ 'Content-Type' => 'application/json'
+ }
+ end
+
+ def build_app_access_token
+ app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
+ app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
+ "#{app_id}|#{app_secret}"
+ end
+
+ def handle_response(response, error_message)
+ raise "#{error_message}: #{response.body}" unless response.success?
+
+ response.parsed_response
+ end
+end
diff --git a/app/services/whatsapp/phone_info_service.rb b/app/services/whatsapp/phone_info_service.rb
new file mode 100644
index 000000000..72a3e53d0
--- /dev/null
+++ b/app/services/whatsapp/phone_info_service.rb
@@ -0,0 +1,57 @@
+class Whatsapp::PhoneInfoService
+ def initialize(waba_id, phone_number_id, access_token)
+ @waba_id = waba_id
+ @phone_number_id = phone_number_id
+ @access_token = access_token
+ @api_client = Whatsapp::FacebookApiClient.new(access_token)
+ end
+
+ def perform
+ validate_parameters!
+ fetch_and_process_phone_info
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
+ raise ArgumentError, 'Access token is required' if @access_token.blank?
+ end
+
+ def fetch_and_process_phone_info
+ response = @api_client.fetch_phone_numbers(@waba_id)
+ phone_numbers = response['data']
+
+ phone_data = find_phone_data(phone_numbers)
+ raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
+
+ build_phone_info(phone_data)
+ end
+
+ def find_phone_data(phone_numbers)
+ return nil if phone_numbers.blank?
+
+ if @phone_number_id.present?
+ phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
+ else
+ phone_numbers.first
+ end
+ end
+
+ def build_phone_info(phone_data)
+ display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
+
+ {
+ phone_number_id: phone_data['id'],
+ phone_number: "+#{display_phone_number}",
+ verified: phone_data['code_verification_status'] == 'VERIFIED',
+ business_name: phone_data['verified_name'] || phone_data['display_phone_number']
+ }
+ end
+
+ def sanitize_phone_number(phone_number)
+ return phone_number if phone_number.blank?
+
+ phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
+ end
+end
diff --git a/app/services/whatsapp/token_exchange_service.rb b/app/services/whatsapp/token_exchange_service.rb
new file mode 100644
index 000000000..112c54831
--- /dev/null
+++ b/app/services/whatsapp/token_exchange_service.rb
@@ -0,0 +1,26 @@
+class Whatsapp::TokenExchangeService
+ def initialize(code)
+ @code = code
+ @api_client = Whatsapp::FacebookApiClient.new
+ end
+
+ def perform
+ validate_code!
+ exchange_token
+ end
+
+ private
+
+ def validate_code!
+ raise ArgumentError, 'Authorization code is required' if @code.blank?
+ end
+
+ def exchange_token
+ response = @api_client.exchange_code_for_token(@code)
+ access_token = response['access_token']
+
+ raise "No access token in response: #{response}" if access_token.blank?
+
+ access_token
+ end
+end
diff --git a/app/services/whatsapp/token_validation_service.rb b/app/services/whatsapp/token_validation_service.rb
new file mode 100644
index 000000000..863f9da51
--- /dev/null
+++ b/app/services/whatsapp/token_validation_service.rb
@@ -0,0 +1,42 @@
+class Whatsapp::TokenValidationService
+ def initialize(access_token, waba_id)
+ @access_token = access_token
+ @waba_id = waba_id
+ @api_client = Whatsapp::FacebookApiClient.new(access_token)
+ end
+
+ def perform
+ validate_parameters!
+ validate_token_waba_access
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'Access token is required' if @access_token.blank?
+ raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
+ end
+
+ def validate_token_waba_access
+ token_debug_data = @api_client.debug_token(@access_token)
+ waba_scope = extract_waba_scope(token_debug_data)
+ verify_waba_authorization(waba_scope)
+ end
+
+ def extract_waba_scope(token_data)
+ granular_scopes = token_data.dig('data', 'granular_scopes')
+ waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
+
+ raise 'No WABA scope found in token' unless waba_scope
+
+ waba_scope
+ end
+
+ def verify_waba_authorization(waba_scope)
+ authorized_waba_ids = waba_scope['target_ids'] || []
+
+ return if authorized_waba_ids.include?(@waba_id)
+
+ raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
+ end
+end
diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb
new file mode 100644
index 000000000..6aff531f7
--- /dev/null
+++ b/app/services/whatsapp/webhook_setup_service.rb
@@ -0,0 +1,67 @@
+class Whatsapp::WebhookSetupService
+ def initialize(channel, waba_id, access_token)
+ @channel = channel
+ @waba_id = waba_id
+ @access_token = access_token
+ @api_client = Whatsapp::FacebookApiClient.new(access_token)
+ end
+
+ def perform
+ validate_parameters!
+ register_phone_number
+ setup_webhook
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'Channel is required' if @channel.blank?
+ raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
+ raise ArgumentError, 'Access token is required' if @access_token.blank?
+ end
+
+ def register_phone_number
+ phone_number_id = @channel.provider_config['phone_number_id']
+ pin = fetch_or_create_pin
+
+ @api_client.register_phone_number(phone_number_id, pin)
+ store_pin(pin)
+ rescue StandardError => e
+ Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
+ # Continue with webhook setup even if registration fails
+ # This is just a warning, not a blocking error
+ end
+
+ def fetch_or_create_pin
+ # Check if we have a stored PIN for this phone number
+ existing_pin = @channel.provider_config['verification_pin']
+ return existing_pin.to_i if existing_pin.present?
+
+ # Generate a new 6-digit PIN if none exists
+ SecureRandom.random_number(900_000) + 100_000
+ end
+
+ def store_pin(pin)
+ # Store the PIN in provider_config for future use
+ @channel.provider_config['verification_pin'] = pin
+ @channel.save!
+ end
+
+ def setup_webhook
+ callback_url = build_callback_url
+ verify_token = @channel.provider_config['webhook_verify_token']
+
+ @api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
+
+ rescue StandardError => e
+ Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
+ raise "Webhook setup failed: #{e.message}"
+ end
+
+ def build_callback_url
+ frontend_url = ENV.fetch('FRONTEND_URL', nil)
+ phone_number = @channel.phone_number
+
+ "#{frontend_url}/webhooks/whatsapp/#{phone_number}"
+ end
+end
diff --git a/app/views/api/v1/accounts/search/articles.json.jbuilder b/app/views/api/v1/accounts/search/articles.json.jbuilder
index 7d4fe031c..f20a163d8 100644
--- a/app/views/api/v1/accounts/search/articles.json.jbuilder
+++ b/app/views/api/v1/accounts/search/articles.json.jbuilder
@@ -4,4 +4,4 @@ json.payload do
json.partial! 'article', formats: [:json], article: article
end
end
-end
\ No newline at end of file
+end
diff --git a/app/views/api/v1/accounts/whatsapp/callbacks/embedded_signup.json.jbuilder b/app/views/api/v1/accounts/whatsapp/callbacks/embedded_signup.json.jbuilder
new file mode 100644
index 000000000..2ad94ff82
--- /dev/null
+++ b/app/views/api/v1/accounts/whatsapp/callbacks/embedded_signup.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb
index 4be41f8b2..8ccac6c48 100644
--- a/app/views/layouts/vueapp.html.erb
+++ b/app/views/layouts/vueapp.html.erb
@@ -39,6 +39,9 @@
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
+ whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
+ whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
+ whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
<% if @global_config['IS_ENTERPRISE'] %>
diff --git a/config/features.yml b/config/features.yml
index d41f705ad..99d7bea6d 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -180,3 +180,6 @@
display_name: Captain V2
enabled: false
premium: true
+- name: whatsapp_embedded_signup
+ display_name: WhatsApp Embedded Signup
+ enabled: false
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 58d593502..b17d3cec0 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -126,6 +126,26 @@
type: boolean
# ------- End of Facebook Channel Related Config ------- #
+# ------- WhatsApp Channel Related Config ------- #
+- name: WHATSAPP_APP_ID
+ display_title: 'WhatsApp App ID'
+ description: 'The Facebook App ID for WhatsApp Business API integration'
+ locked: false
+- name: WHATSAPP_CONFIGURATION_ID
+ display_title: 'WhatsApp Configuration ID'
+ description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
+ locked: false
+- name: WHATSAPP_APP_SECRET
+ display_title: 'WhatsApp App Secret'
+ description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
+ locked: false
+- name: WHATSAPP_API_VERSION
+ display_title: 'WhatsApp API Version'
+ description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
+ value: 'v22.0'
+ locked: false
+# ------- End of WhatsApp Channel Related Config ------- #
+
# MARK: Microsoft Email Channel Config
- name: AZURE_APP_ID
display_title: 'Azure App ID'
diff --git a/config/routes.rb b/config/routes.rb
index 4aad60039..f262febdf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -233,6 +233,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
+ namespace :whatsapp do
+ resource :authorization, only: [:create]
+ end
+
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
diff --git a/db/migrate/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb
index 9e2a25723..2d1a41e74 100644
--- a/db/migrate/20250620120000_create_channel_voice.rb
+++ b/db/migrate/20250620120000_create_channel_voice.rb
@@ -13,4 +13,4 @@ class CreateChannelVoice < ActiveRecord::Migration[7.0]
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
-end
\ No newline at end of file
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
index 7c329053b..376cee0b3 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
@@ -44,4 +44,4 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
def scenario_params
params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: [])
end
-end
\ No newline at end of file
+end
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index e86f66832..f2b2b263c 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -103,6 +103,12 @@ slack:
enabled: true
icon: 'icon-slack'
config_key: 'slack'
+whatsapp_embedded:
+ name: 'WhatsApp Embedded'
+ description: 'Configuration for setting up WhatsApp Embedded Integration'
+ enabled: true
+ icon: 'icon-whatsapp-line'
+ config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
index a313129e2..20ae5a74c 100644
--- a/enterprise/app/models/channel/voice.rb
+++ b/enterprise/app/models/channel/voice.rb
@@ -61,4 +61,3 @@ class Channel::Voice < ApplicationRecord
end
end
end
-
diff --git a/enterprise/app/policies/captain/scenario_policy.rb b/enterprise/app/policies/captain/scenario_policy.rb
index 82c5d236b..d532b5b92 100644
--- a/enterprise/app/policies/captain/scenario_policy.rb
+++ b/enterprise/app/policies/captain/scenario_policy.rb
@@ -18,4 +18,4 @@ class Captain::ScenarioPolicy < ApplicationPolicy
def destroy?
@account_user.administrator?
end
-end
\ No newline at end of file
+end
diff --git a/enterprise/app/services/captain/open_ai_message_builder_service.rb b/enterprise/app/services/captain/open_ai_message_builder_service.rb
index 43d2851c9..4aaa64e0a 100644
--- a/enterprise/app/services/captain/open_ai_message_builder_service.rb
+++ b/enterprise/app/services/captain/open_ai_message_builder_service.rb
@@ -57,4 +57,4 @@ class Captain::OpenAiMessageBuilderService
result[:success] ? result[:transcriptions] : ''
end.join
end
-end
\ No newline at end of file
+end
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder
index 98566b10f..8963e07c5 100644
--- a/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder
@@ -1 +1 @@
-json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
\ No newline at end of file
+json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
index 60e97a017..dc5860fb9 100644
--- a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
@@ -2,4 +2,4 @@ json.data do
json.array! @scenarios do |scenario|
json.partial! 'api/v1/models/captain/scenario', scenario: scenario
end
-end
\ No newline at end of file
+end
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder
index 98566b10f..8963e07c5 100644
--- a/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder
@@ -1 +1 @@
-json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
\ No newline at end of file
+json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder
index 98566b10f..8963e07c5 100644
--- a/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder
@@ -1 +1 @@
-json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
\ No newline at end of file
+json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
diff --git a/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder
index ae78c7bb8..a0a832efc 100644
--- a/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder
@@ -13,4 +13,4 @@ if scenario.assistant.present?
json.id scenario.assistant.id
json.name scenario.assistant.name
end
-end
\ No newline at end of file
+end
diff --git a/public/assets/images/dashboard/channels/whatsapp.png b/public/assets/images/dashboard/channels/whatsapp.png
index 547ff675e..7bee740d9 100644
Binary files a/public/assets/images/dashboard/channels/whatsapp.png and b/public/assets/images/dashboard/channels/whatsapp.png differ
diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
index ac4bc2841..b003d5c85 100644
--- a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
@@ -50,4 +50,4 @@ RSpec.describe 'Notion Authorization API', type: :request do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb
new file mode 100644
index 000000000..eca193c76
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb
@@ -0,0 +1,303 @@
+require 'rails_helper'
+
+RSpec.describe 'WhatsApp Authorization API', type: :request do
+ let(:account) { create(:account) }
+
+ describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+
+ context 'when feature is not enabled' do
+ before do
+ account.disable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'returns forbidden' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account')
+ end
+ end
+
+ context 'when feature is enabled' do
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'returns unprocessable entity when code is missing' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to include('code')
+ end
+
+ it 'returns unprocessable entity when business_id is missing' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to include('business_id')
+ end
+
+ it 'returns unprocessable entity when waba_id is missing' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to include('waba_id')
+ end
+
+ it 'creates whatsapp channel successfully' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service to prevent HTTP calls
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: 'test_phone_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ response_data = response.parsed_body
+ expect(response_data['success']).to be true
+ expect(response_data['id']).to eq(inbox.id)
+ expect(response_data['name']).to eq(inbox.name)
+ expect(response_data['channel_type']).to eq('whatsapp')
+ end
+
+ it 'calls the embedded signup service with correct parameters' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+
+ expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
+ account: account,
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: 'test_phone_id'
+ ).and_return(embedded_signup_service)
+
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: 'test_phone_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+ end
+
+ it 'accepts phone_number_id as optional parameter' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+
+ expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
+ account: account,
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: nil
+ ).and_return(embedded_signup_service)
+
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns unprocessable entity when service fails' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ response_data = response.parsed_body
+ expect(response_data['success']).to be false
+ expect(response_data['error']).to eq('Service error')
+ end
+
+ it 'logs error when service fails' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
+
+ expect(Rails.logger).to receive(:error).with(/\[WHATSAPP AUTHORIZATION\] Embedded signup error: Service error/)
+ expect(Rails.logger).to receive(:error).with(/authorizations_controller/)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+ end
+
+ it 'handles token exchange errors' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new)
+ .and_raise(StandardError, 'Invalid authorization code')
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'invalid_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Invalid authorization code')
+ end
+
+ it 'handles channel already exists error' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new)
+ .and_raise(StandardError, 'Channel already exists')
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Channel already exists')
+ end
+ end
+
+ context 'when user is not authorized for the account' do
+ let(:other_account) { create(:account) }
+
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{other_account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is an administrator' do
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'allows channel creation' do
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: administrator.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
index c66571921..ed223622b 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
@@ -255,4 +255,4 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb
index f944373e2..1f13a362b 100644
--- a/spec/enterprise/models/captain/scenario_spec.rb
+++ b/spec/enterprise/models/captain/scenario_spec.rb
@@ -60,4 +60,4 @@ RSpec.describe Captain::Scenario, type: :model do
expect(scenario.account).to be_present
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 4368807fe..29a00fd96 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -61,4 +61,65 @@ RSpec.describe Channel::Whatsapp do
expect(channel.provider_config['webhook_verify_token']).to eq '123'
end
end
+
+ describe 'webhook setup after creation' do
+ let(:account) { create(:account) }
+ let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
+
+ before do
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+ end
+
+ context 'when channel is created through embedded signup' do
+ it 'does not raise error if webhook setup fails' do
+ allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
+
+ expect do
+ create(:channel_whatsapp,
+ account: account,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'source' => 'embedded_signup',
+ 'business_account_id' => 'test_waba_id',
+ 'api_key' => 'test_access_token'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+ end.not_to raise_error
+ end
+ end
+
+ context 'when channel is created through manual setup' do
+ it 'does not setup webhooks' do
+ expect(Whatsapp::WebhookSetupService).not_to receive(:new)
+
+ create(:channel_whatsapp,
+ account: account,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'business_account_id' => 'test_waba_id',
+ 'api_key' => 'test_access_token'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+ end
+ end
+
+ context 'when channel is created with different provider' do
+ it 'does not setup webhooks for 360dialog provider' do
+ expect(Whatsapp::WebhookSetupService).not_to receive(:new)
+
+ create(:channel_whatsapp,
+ account: account,
+ provider: 'default',
+ provider_config: {
+ 'source' => 'embedded_signup',
+ 'api_key' => 'test_360dialog_key'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb
new file mode 100644
index 000000000..1cf0b87f2
--- /dev/null
+++ b/spec/models/concerns/featurable_spec.rb
@@ -0,0 +1,57 @@
+require 'rails_helper'
+
+RSpec.describe Featurable do
+ let(:account) { create(:account) }
+
+ describe 'WhatsApp embedded signup feature' do
+ it 'is disabled by default' do
+ expect(account.feature_whatsapp_embedded_signup?).to be false
+ expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false
+ end
+
+ describe '#enable_features!' do
+ it 'enables the whatsapp embedded signup feature' do
+ account.enable_features!(:whatsapp_embedded_signup)
+ expect(account.feature_whatsapp_embedded_signup?).to be true
+ expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true
+ end
+
+ it 'enables multiple features at once' do
+ account.enable_features!(:whatsapp_embedded_signup, :help_center)
+ expect(account.feature_whatsapp_embedded_signup?).to be true
+ expect(account.feature_help_center?).to be true
+ end
+ end
+
+ describe '#disable_features!' do
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'disables the whatsapp embedded signup feature' do
+ expect(account.feature_whatsapp_embedded_signup?).to be true
+
+ account.disable_features!(:whatsapp_embedded_signup)
+ expect(account.feature_whatsapp_embedded_signup?).to be false
+ end
+ end
+
+ describe '#enabled_features' do
+ it 'includes whatsapp_embedded_signup when enabled' do
+ account.enable_features!(:whatsapp_embedded_signup)
+ expect(account.enabled_features).to include('whatsapp_embedded_signup' => true)
+ end
+
+ it 'does not include whatsapp_embedded_signup when disabled' do
+ account.disable_features!(:whatsapp_embedded_signup)
+ expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true)
+ end
+ end
+
+ describe '#all_features' do
+ it 'includes whatsapp_embedded_signup in all features list' do
+ expect(account.all_features).to have_key('whatsapp_embedded_signup')
+ end
+ end
+ end
+end
diff --git a/spec/presenters/message_content_presenter_spec.rb b/spec/presenters/message_content_presenter_spec.rb
index f85bdb8d1..fe709fd21 100644
--- a/spec/presenters/message_content_presenter_spec.rb
+++ b/spec/presenters/message_content_presenter_spec.rb
@@ -63,4 +63,4 @@ RSpec.describe MessageContentPresenter do
expect(presenter.conversation).to eq(conversation)
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/services/csat_survey_service_spec.rb b/spec/services/csat_survey_service_spec.rb
index 5b3b3279a..ecb5a75c6 100644
--- a/spec/services/csat_survey_service_spec.rb
+++ b/spec/services/csat_survey_service_spec.rb
@@ -88,4 +88,4 @@ describe CsatSurveyService do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/services/linear/activity_message_service_spec.rb b/spec/services/linear/activity_message_service_spec.rb
index 4b51f6520..e950ec8e3 100644
--- a/spec/services/linear/activity_message_service_spec.rb
+++ b/spec/services/linear/activity_message_service_spec.rb
@@ -171,4 +171,4 @@ RSpec.describe Linear::ActivityMessageService, type: :service do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
new file mode 100644
index 000000000..1c1f46232
--- /dev/null
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -0,0 +1,119 @@
+require 'rails_helper'
+
+describe Whatsapp::ChannelCreationService do
+ let(:account) { create(:account) }
+ let(:waba_info) { { waba_id: 'test_waba_id', business_name: 'Test Business' } }
+ let(:phone_info) do
+ {
+ phone_number_id: 'test_phone_id',
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ }
+ end
+ let(:access_token) { 'test_access_token' }
+ let(:service) { described_class.new(account, waba_info, phone_info, access_token) }
+
+ describe '#perform' do
+ before do
+ # Clean up any existing channels to avoid phone number conflicts
+ Channel::Whatsapp.destroy_all
+
+ # Stub the webhook setup service to prevent HTTP calls during tests
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ # Stub the provider validation and sync_templates
+ allow(Channel::Whatsapp).to receive(:new).and_wrap_original do |method, *args|
+ channel = method.call(*args)
+ allow(channel).to receive(:validate_provider_config)
+ allow(channel).to receive(:sync_templates)
+ channel
+ end
+ end
+
+ context 'when channel does not exist' do
+ it 'creates a new channel' do
+ expect { service.perform }.to change(Channel::Whatsapp, :count).by(1)
+ end
+
+ it 'creates channel with correct attributes' do
+ channel = service.perform
+ expect(channel.phone_number).to eq('+1234567890')
+ expect(channel.provider).to eq('whatsapp_cloud')
+ expect(channel.provider_config['api_key']).to eq(access_token)
+ expect(channel.provider_config['phone_number_id']).to eq('test_phone_id')
+ expect(channel.provider_config['business_account_id']).to eq('test_waba_id')
+ expect(channel.provider_config['source']).to eq('embedded_signup')
+ end
+
+ it 'creates an inbox for the channel' do
+ channel = service.perform
+ inbox = channel.inbox
+ expect(inbox).not_to be_nil
+ expect(inbox.name).to eq('Test Business WhatsApp')
+ expect(inbox.account).to eq(account)
+ end
+ end
+
+ context 'when channel already exists' do
+ before do
+ create(:channel_whatsapp, account: account, phone_number: '+1234567890',
+ provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Channel already exists/)
+ end
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises error when account is nil' do
+ service = described_class.new(nil, waba_info, phone_info, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'Account is required')
+ end
+
+ it 'raises error when waba_info is nil' do
+ service = described_class.new(account, nil, phone_info, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA info is required')
+ end
+
+ it 'raises error when phone_info is nil' do
+ service = described_class.new(account, waba_info, nil, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'Phone info is required')
+ end
+
+ it 'raises error when access_token is blank' do
+ service = described_class.new(account, waba_info, phone_info, '')
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when business_name is in different places' do
+ context 'when business_name is only in phone_info' do
+ let(:waba_info) { { waba_id: 'test_waba_id' } }
+
+ it 'uses business_name from phone_info' do
+ channel = service.perform
+ expect(channel.inbox.name).to eq('Test Business WhatsApp')
+ end
+ end
+
+ context 'when business_name is only in waba_info' do
+ let(:phone_info) do
+ {
+ phone_number_id: 'test_phone_id',
+ phone_number: '+1234567890',
+ verified: true
+ }
+ end
+
+ it 'uses business_name from waba_info' do
+ channel = service.perform
+ expect(channel.inbox.name).to eq('Test Business WhatsApp')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
new file mode 100644
index 000000000..95af73523
--- /dev/null
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -0,0 +1,127 @@
+require 'rails_helper'
+
+describe Whatsapp::EmbeddedSignupService do
+ let(:account) { create(:account) }
+ let(:code) { 'test_authorization_code' }
+ let(:business_id) { 'test_business_id' }
+ let(:waba_id) { 'test_waba_id' }
+ let(:phone_number_id) { 'test_phone_number_id' }
+ let(:service) do
+ described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ end
+
+ describe '#perform' do
+ let(:access_token) { 'test_access_token' }
+ let(:phone_info) do
+ {
+ phone_number_id: phone_number_id,
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ }
+ end
+ let(:channel) { instance_double(Channel::Whatsapp) }
+
+ let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
+ let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
+ let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
+ let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
+
+ before do
+ allow(GlobalConfig).to receive(:clear_cache)
+
+ allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
+ allow(token_exchange_service).to receive(:perform).and_return(access_token)
+
+ allow(Whatsapp::PhoneInfoService).to receive(:new)
+ .with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
+ allow(phone_info_service).to receive(:perform).and_return(phone_info)
+
+ allow(Whatsapp::TokenValidationService).to receive(:new)
+ .with(access_token, waba_id).and_return(token_validation_service)
+ allow(token_validation_service).to receive(:perform)
+
+ allow(Whatsapp::ChannelCreationService).to receive(:new)
+ .with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
+ .and_return(channel_creation_service)
+ allow(channel_creation_service).to receive(:perform).and_return(channel)
+
+ # Webhook setup is now handled in the channel after_create callback
+ # So we stub it at the model level
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+ end
+
+ it 'orchestrates all services in the correct order' do
+ expect(token_exchange_service).to receive(:perform).ordered
+ expect(phone_info_service).to receive(:perform).ordered
+ expect(token_validation_service).to receive(:perform).ordered
+ expect(channel_creation_service).to receive(:perform).ordered
+
+ result = service.perform
+ expect(result).to eq(channel)
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises error when code is blank' do
+ service = described_class.new(
+ account: account,
+ code: '',
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
+ end
+
+ it 'raises error when business_id is blank' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: '',
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
+ end
+
+ it 'raises error when waba_id is blank' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: '',
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
+ end
+
+ it 'raises error when multiple parameters are blank' do
+ service = described_class.new(
+ account: account,
+ code: '',
+ business_id: '',
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
+ end
+ end
+
+ context 'when any service fails' do
+ it 'logs and re-raises the error' do
+ allow(token_exchange_service).to receive(:perform).and_raise('Token error')
+
+ expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
+ expect { service.perform }.to raise_error('Token error')
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb
new file mode 100644
index 000000000..6c2af7716
--- /dev/null
+++ b/spec/services/whatsapp/facebook_api_client_spec.rb
@@ -0,0 +1,197 @@
+require 'rails_helper'
+
+describe Whatsapp::FacebookApiClient do
+ let(:access_token) { 'test_access_token' }
+ let(:api_client) { described_class.new(access_token) }
+ let(:api_version) { 'v22.0' }
+ let(:app_id) { 'test_app_id' }
+ let(:app_secret) { 'test_app_secret' }
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
+ end
+
+ describe '#exchange_code_for_token' do
+ let(:code) { 'test_code' }
+
+ context 'when successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: { client_id: app_id, client_secret: app_secret, code: code })
+ .to_return(
+ status: 200,
+ body: { access_token: 'new_token' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the response data' do
+ result = api_client.exchange_code_for_token(code)
+ expect(result['access_token']).to eq('new_token')
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: { client_id: app_id, client_secret: app_secret, code: code })
+ .to_return(status: 400, body: { error: 'Invalid code' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.exchange_code_for_token(code) }.to raise_error(/Token exchange failed/)
+ end
+ end
+ end
+
+ describe '#fetch_phone_numbers' do
+ let(:waba_id) { 'test_waba_id' }
+
+ context 'when successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: { access_token: access_token })
+ .to_return(
+ status: 200,
+ body: { data: [{ id: '123', display_phone_number: '1234567890' }] }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the phone numbers data' do
+ result = api_client.fetch_phone_numbers(waba_id)
+ expect(result['data']).to be_an(Array)
+ expect(result['data'].first['id']).to eq('123')
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: { access_token: access_token })
+ .to_return(status: 403, body: { error: 'Access denied' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.fetch_phone_numbers(waba_id) }.to raise_error(/WABA phone numbers fetch failed/)
+ end
+ end
+ end
+
+ describe '#debug_token' do
+ let(:input_token) { 'test_input_token' }
+ let(:app_access_token) { "#{app_id}|#{app_secret}" }
+
+ context 'when successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: { input_token: input_token, access_token: app_access_token })
+ .to_return(
+ status: 200,
+ body: { data: { app_id: app_id, is_valid: true } }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the debug token data' do
+ result = api_client.debug_token(input_token)
+ expect(result['data']['is_valid']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: { input_token: input_token, access_token: app_access_token })
+ .to_return(status: 400, body: { error: 'Invalid token' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.debug_token(input_token) }.to raise_error(/Token validation failed/)
+ end
+ end
+ end
+
+ describe '#register_phone_number' do
+ let(:phone_number_id) { 'test_phone_id' }
+ let(:pin) { '123456' }
+
+ context 'when successful' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { messaging_product: 'whatsapp', pin: pin }.to_json
+ )
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns success response' do
+ result = api_client.register_phone_number(phone_number_id, pin)
+ expect(result['success']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { messaging_product: 'whatsapp', pin: pin }.to_json
+ )
+ .to_return(status: 400, body: { error: 'Registration failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.register_phone_number(phone_number_id, pin) }.to raise_error(/Phone registration failed/)
+ end
+ end
+ end
+
+ describe '#subscribe_waba_webhook' do
+ let(:waba_id) { 'test_waba_id' }
+ let(:callback_url) { 'https://example.com/webhook' }
+ let(:verify_token) { 'test_verify_token' }
+
+ context 'when successful' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
+ )
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns success response' do
+ result = api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token)
+ expect(result['success']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
+ )
+ .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/phone_info_service_spec.rb b/spec/services/whatsapp/phone_info_service_spec.rb
new file mode 100644
index 000000000..bf1517a39
--- /dev/null
+++ b/spec/services/whatsapp/phone_info_service_spec.rb
@@ -0,0 +1,147 @@
+require 'rails_helper'
+
+describe Whatsapp::PhoneInfoService do
+ let(:waba_id) { 'test_waba_id' }
+ let(:phone_number_id) { 'test_phone_number_id' }
+ let(:access_token) { 'test_access_token' }
+ let(:service) { described_class.new(waba_id, phone_number_id, access_token) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
+ end
+
+ describe '#perform' do
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => phone_number_id,
+ 'display_phone_number' => '1234567890',
+ 'verified_name' => 'Test Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ context 'when all parameters are valid' do
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'returns formatted phone info' do
+ result = service.perform
+ expect(result).to eq({
+ phone_number_id: phone_number_id,
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ })
+ end
+ end
+
+ context 'when phone_number_id is not provided' do
+ let(:phone_number_id) { nil }
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => 'first_phone_id',
+ 'display_phone_number' => '1234567890',
+ 'verified_name' => 'Test Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'uses the first available phone number' do
+ result = service.perform
+ expect(result[:phone_number_id]).to eq('first_phone_id')
+ end
+ end
+
+ context 'when specific phone_number_id is not found' do
+ let(:phone_number_id) { 'different_id' }
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => 'available_phone_id',
+ 'display_phone_number' => '9876543210',
+ 'verified_name' => 'Different Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'uses the first available phone number as fallback' do
+ result = service.perform
+ expect(result[:phone_number_id]).to eq('available_phone_id')
+ expect(result[:phone_number]).to eq('+9876543210')
+ end
+ end
+
+ context 'when no phone numbers are available' do
+ let(:phone_response) { { 'data' => [] } }
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/No phone numbers found for WABA/)
+ end
+ end
+
+ context 'when waba_id is blank' do
+ let(:waba_id) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
+ end
+ end
+
+ context 'when access_token is blank' do
+ let(:access_token) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when phone number has special characters' do
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => phone_number_id,
+ 'display_phone_number' => '+1 (234) 567-8900',
+ 'verified_name' => 'Test Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'sanitizes the phone number' do
+ result = service.perform
+ expect(result[:phone_number]).to eq('+12345678900')
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/token_exchange_service_spec.rb b/spec/services/whatsapp/token_exchange_service_spec.rb
new file mode 100644
index 000000000..cbe97751b
--- /dev/null
+++ b/spec/services/whatsapp/token_exchange_service_spec.rb
@@ -0,0 +1,45 @@
+require 'rails_helper'
+
+describe Whatsapp::TokenExchangeService do
+ let(:code) { 'test_authorization_code' }
+ let(:service) { described_class.new(code) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ end
+
+ describe '#perform' do
+ context 'when code is valid' do
+ let(:token_response) { { 'access_token' => 'new_access_token' } }
+
+ before do
+ allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
+ end
+
+ it 'returns the access token' do
+ expect(service.perform).to eq('new_access_token')
+ end
+ end
+
+ context 'when code is blank' do
+ let(:service) { described_class.new('') }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'Authorization code is required')
+ end
+ end
+
+ context 'when response has no access token' do
+ let(:token_response) { { 'error' => 'Invalid code' } }
+
+ before do
+ allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/No access token in response/)
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/token_validation_service_spec.rb b/spec/services/whatsapp/token_validation_service_spec.rb
new file mode 100644
index 000000000..d9cf40257
--- /dev/null
+++ b/spec/services/whatsapp/token_validation_service_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+describe Whatsapp::TokenValidationService do
+ let(:access_token) { 'test_access_token' }
+ let(:waba_id) { 'test_waba_id' }
+ let(:service) { described_class.new(access_token, waba_id) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
+ end
+
+ describe '#perform' do
+ context 'when token has access to WABA' do
+ let(:debug_response) do
+ {
+ 'data' => {
+ 'granular_scopes' => [
+ {
+ 'scope' => 'whatsapp_business_management',
+ 'target_ids' => [waba_id, 'another_waba_id']
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
+ end
+
+ it 'validates successfully' do
+ expect { service.perform }.not_to raise_error
+ end
+ end
+
+ context 'when token does not have access to WABA' do
+ let(:debug_response) do
+ {
+ 'data' => {
+ 'granular_scopes' => [
+ {
+ 'scope' => 'whatsapp_business_management',
+ 'target_ids' => ['different_waba_id']
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Token does not have access to WABA/)
+ end
+ end
+
+ context 'when no WABA scope is found' do
+ let(:debug_response) do
+ {
+ 'data' => {
+ 'granular_scopes' => [
+ {
+ 'scope' => 'some_other_scope',
+ 'target_ids' => ['some_id']
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error('No WABA scope found in token')
+ end
+ end
+
+ context 'when access_token is blank' do
+ let(:access_token) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when waba_id is blank' do
+ let(:waba_id) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb
new file mode 100644
index 000000000..89beca922
--- /dev/null
+++ b/spec/services/whatsapp/webhook_setup_service_spec.rb
@@ -0,0 +1,119 @@
+require 'rails_helper'
+
+describe Whatsapp::WebhookSetupService do
+ let(:channel) do
+ create(:channel_whatsapp,
+ phone_number: '+1234567890',
+ provider_config: {
+ 'phone_number_id' => 'test_phone_id',
+ 'webhook_verify_token' => 'test_verify_token'
+ },
+ provider: 'whatsapp_cloud',
+ sync_templates: false,
+ validate_provider_config: false)
+ end
+ let(:waba_id) { 'test_waba_id' }
+ let(:access_token) { 'test_access_token' }
+ let(:service) { described_class.new(channel, waba_id, access_token) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ # Clean up any existing channels to avoid phone number conflicts
+ Channel::Whatsapp.destroy_all
+ allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ end
+
+ describe '#perform' do
+ context 'when all operations succeed' do
+ before do
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'test_verify_token')
+ .and_return({ 'success' => true })
+ allow(channel).to receive(:save!)
+ end
+
+ it 'registers the phone number' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ service.perform
+ end
+ end
+
+ it 'sets up webhook subscription' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
+ service.perform
+ end
+ end
+ end
+
+ context 'when phone registration fails' do
+ before do
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number)
+ .and_raise('Registration failed')
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .and_return({ 'success' => true })
+ end
+
+ it 'continues with webhook setup' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ expect { service.perform }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when webhook setup fails' do
+ before do
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .and_raise('Webhook failed')
+ end
+
+ it 'raises an error' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect { service.perform }.to raise_error(/Webhook setup failed/)
+ end
+ end
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises error when channel is nil' do
+ service = described_class.new(nil, waba_id, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'Channel is required')
+ end
+
+ it 'raises error when waba_id is blank' do
+ service = described_class.new(channel, '', access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
+ end
+
+ it 'raises error when access_token is blank' do
+ service = described_class.new(channel, waba_id, '')
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when PIN already exists' do
+ before do
+ channel.provider_config['verification_pin'] = 123_456
+ allow(api_client).to receive(:register_phone_number)
+ allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
+ allow(channel).to receive(:save!)
+ end
+
+ it 'reuses existing PIN' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:register_phone_number).with('123456789', 123_456)
+ expect(SecureRandom).not_to receive(:random_number)
+ service.perform
+ end
+ end
+ end
+ end
+end