diff --git a/app/admin/billing/services.rb b/app/admin/billing/services.rb index 67392c4f8..b6d0beb23 100644 --- a/app/admin/billing/services.rb +++ b/app/admin/billing/services.rb @@ -3,6 +3,15 @@ ActiveAdmin.register Billing::Service, as: 'Services' do menu parent: 'Billing', label: 'Services', priority: 30 + controller do + def create_resource(object) + object.save + rescue Billing::Provisioning::Errors::Error => e + flash[:warning] = e.message + false + end + end + acts_as_audit acts_as_clone acts_as_safe_destroy diff --git a/app/models/billing/provisioning/phone_systems.rb b/app/models/billing/provisioning/phone_systems.rb new file mode 100644 index 000000000..fba3aaba1 --- /dev/null +++ b/app/models/billing/provisioning/phone_systems.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems < Base + def verify_service_variables! + contract = ServiceVariablesContract.new + result = contract.call(service.variables) + raise Billing::Provisioning::Errors::InvalidVariablesError, result.errors.to_h unless result.success? + + service.variables + end + + def after_create + CustomerService.create_customer(service) + end + + def before_destroy + CustomerService.delete_customer(service) + end + + def self.verify_service_type_variables!(service_type) + contract = ServiceTypeVariablesContract.new + result = contract.call(service_type.variables) + raise Billing::Provisioning::Errors::InvalidVariablesError, result.errors.to_h unless result.success? + + service_type.variables + end + end + end +end diff --git a/app/models/billing/provisioning/phone_systems/README.md b/app/models/billing/provisioning/phone_systems/README.md new file mode 100644 index 000000000..932356f97 --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/README.md @@ -0,0 +1,43 @@ +# Billing::Provisioning::PhoneSystems + +This module is responsible for provisioning customers and services through the phone.systems platform. +It provides an interface for creating and deleting customers in an external VoIP or telecom system through +REST API interactions. The code follows clean architecture principles, making it easy to extend and maintain. + +## Overview + +The `Billing::Provisioning::PhoneSystems` namespace handles communication with the external `phone.systems` API. +This includes managing customer data such as creating new customers, deleting customers, and sending necessary service +variables for the telecom center. + +The main classes and schemas in this module ensure data validation, API interaction, +and seamless handling of customer records. + +## Key Components + +- `CustomerService`: Manages operations for creating and deleting customers on phone.systems. +It sends payloads to the API and processes the response. +- `PhoneSystemsApiClient`: Handles HTTP requests to the phone.systems API. It sends POST and DELETE requests for +creating and deleting customers with the appropriate authentication and headers. +- `ServiceTypeSchema` and `ServiceVariablesSchema`: These define validation rules for the service data +(like endpoint, username, password, and customer attributes). These schemas ensure only valid data is sent to the +phone.systems API. +- `ServiceTypeVariablesContract` and `ServiceVariablesContract`: These contracts apply the validation logic from the +schemas, ensuring consistency in data formats and preventing invalid data from being sent to the API. + +## ServiceTypeSchema Example + +```json +{ + endpoint: 'https://api.sandbox.telecom.center', + username: 'test', + password: 'test', + attributes: { + name: 'Customer Name', + language: 'EN', + capacity_limit: 100, + "trm_mode": 'operator', + sip_account_limit: 50 + } +} +``` diff --git a/app/models/billing/provisioning/phone_systems/customer_service.rb b/app/models/billing/provisioning/phone_systems/customer_service.rb new file mode 100644 index 000000000..b35af2115 --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/customer_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems + class CustomerService + def initialize(service) + @service = service + @service_variables = service.type.variables.merge(service.variables) + @api_client = PhoneSystemsApiClient.new(@service_variables) + end + + def create_customer + payload = { + data: { + id: @service.id, + type: 'customers', + attributes: @service_variables['attributes'] + } + } + + response = @api_client.create_customer(payload) + process_response(response, 'create') do |response_body| + @service.update(id: response_body.dig('data', 'id')) + end + end + + def delete_customer + response = @api_client.delete_customer(@service.id) + process_response(response, 'delete') + end + + def self.delete_customer(service) + new(service).delete_customer + end + + def self.create_customer(service) + new(service).create_customer + end + + private + + def process_response(response, action) + if response.success? + Rails.logger.info "Customer #{action}d successfully on telecom.center" + yield JSON.parse(response.body) if block_given? + else + handle_error(response) + end + end + + def handle_error(response) + error_message = retrieve_validation_error(response) + Rails.logger.error error_message + raise Billing::Provisioning::Errors::Error, error_message + end + + def retrieve_validation_error(response) + response_body = JSON.parse(response.body) + response_body.dig('errors', 0, 'detail') + rescue StandardError + 'Unknown error' + end + end + end + end +end diff --git a/app/models/billing/provisioning/phone_systems/phone_systems_api_client.rb b/app/models/billing/provisioning/phone_systems/phone_systems_api_client.rb new file mode 100644 index 000000000..2e979b8ac --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/phone_systems_api_client.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems + class PhoneSystemsApiClient + def initialize(service_variables) + @debug = service_variables.fetch(:debug, true) + @api_endpoint = service_variables['endpoint'].chomp('/') + @api_credentials = { + username: service_variables['username'], + password: service_variables['password'] + } + end + + def create_customer(payload) + post_request('/api/rest/public/operator/customers', payload) + end + + def delete_customer(customer_id) + delete_request("/api/rest/public/operator/customers/#{customer_id}") + end + + private + + def post_request(path, payload) + HTTParty.post( + "#{@api_endpoint}#{path}", + body: payload.to_json, + basic_auth: @api_credentials, + headers: { 'Content-Type' => 'application/vnd.api+json' }, + debug_output: @debug ? $stdout : false + ) + end + + def delete_request(path) + HTTParty.delete( + "#{@api_endpoint}#{path}", + basic_auth: @api_credentials, + headers: { 'Content-Type' => 'application/vnd.api+json' }, + debug_output: @debug ? $stdout : false + ) + end + end + end + end +end diff --git a/app/models/billing/provisioning/phone_systems/service_type_schema.rb b/app/models/billing/provisioning/phone_systems/service_type_schema.rb new file mode 100644 index 000000000..db8d10940 --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/service_type_schema.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems + ServiceTypeSchema = Dry::Schema.JSON do + required(:endpoint).filled(:string) + required(:username).filled(:string) + required(:password).filled(:string) + + required(:attributes).filled(:hash).schema do + required(:name).filled(:string) + optional(:language).filled(:string) + optional(:trm_mode).filled(:string) + optional(:capacity_limit).value(:integer) + optional(:sip_account_limit).value(:integer) + end + end + end + end +end diff --git a/app/models/billing/provisioning/phone_systems/service_type_variables_contract.rb b/app/models/billing/provisioning/phone_systems/service_type_variables_contract.rb new file mode 100644 index 000000000..d02ad03f1 --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/service_type_variables_contract.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems + class ServiceTypeVariablesContract < Dry::Validation::Contract + json(ServiceTypeSchema) + end + end + end +end diff --git a/app/models/billing/provisioning/phone_systems/service_variables_contract.rb b/app/models/billing/provisioning/phone_systems/service_variables_contract.rb new file mode 100644 index 000000000..d11594214 --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/service_variables_contract.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems + class ServiceVariablesContract < Dry::Validation::Contract + json(ServiceVariablesSchema) + end + end + end +end diff --git a/app/models/billing/provisioning/phone_systems/service_variables_schema.rb b/app/models/billing/provisioning/phone_systems/service_variables_schema.rb new file mode 100644 index 000000000..8371edb8e --- /dev/null +++ b/app/models/billing/provisioning/phone_systems/service_variables_schema.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Billing + module Provisioning + class PhoneSystems + ServiceVariablesSchema = Dry::Schema.JSON do + optional(:endpoint).filled(:string) + optional(:username).filled(:string) + optional(:password).filled(:string) + + required(:attributes).filled(:hash).schema do + required(:name).filled(:string) + optional(:language).filled(:string) + optional(:trm_mode).filled(:string) + optional(:capacity_limit).value(:integer) + optional(:sip_account_limit).value(:integer) + end + end + end + end +end diff --git a/spec/models/billing/provisioning/phone_systems_spec.rb b/spec/models/billing/provisioning/phone_systems_spec.rb new file mode 100644 index 000000000..9c146d13b --- /dev/null +++ b/spec/models/billing/provisioning/phone_systems_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +RSpec.describe Billing::Provisioning::PhoneSystems, type: :model do + let(:telecom_center_api_host) { 'https://api.telecom.center' } + let(:telecom_center_api_endpoint) { "#{telecom_center_api_host}/api/rest/public/operator/customers" } + let(:service_type_attrs) { { variables: { endpoint: telecom_center_api_host, username: 'user', password: 'pass' } } } + let(:service_type) { FactoryBot.create(:service_type, service_type_attrs) } + let(:service_attrs) do + { + type: service_type, + variables: { + attributes: { + name: 'Test Service', language: 'EN', trm_mode: 'AUTO', capacity_limit: 100, sip_account_limit: 10 + } + } + } + end + let(:service) { FactoryBot.create(:service, service_attrs) } + let(:response_body_from_telecom_center) do + { + data: { + id: 123, + type: 'customers', + attributes: { + name: 'Test Service', + language: 'EN', + trm_mode: 'AUTO', + capacity_limit: 100, + sip_account_limit: 10 + } + } + } + end + let(:auth_header) { 'Basic dXNlcjpwYXNz' } + + before do + allow(service).to receive(:update) + WebMock.reset! + end + + describe '#after_create' do + subject { described_class.new(service).after_create } + + context 'when customer creation is successful' do + before do + WebMock + .stub_request(:post, telecom_center_api_endpoint) + .with( + body: { data: { id: service.id, type: 'customers', attributes: service.variables['attributes'] } }.to_json, + headers: { + 'Authorization' => auth_header, + 'Content-Type' => 'application/vnd.api+json' + } + ) + .to_return(status: 200, body: { data: { id: 123 } }.to_json) + end + + it 'sends a POST request to create the customer' do + subject + expect(WebMock).to have_requested(:post, telecom_center_api_endpoint).once + expect(service).to have_received(:update).with(id: 123) + end + end + + context 'when customer creation fails with a validation error' do + let(:error_body) { { errors: [{ title: 'Language not found!', detail: 'Language not found!' }] } } + + before do + WebMock + .stub_request(:post, telecom_center_api_endpoint) + .to_return(status: 422, body: error_body.to_json) + end + + it 'raises a validation error' do + expect { subject }.to raise_error(Billing::Provisioning::Errors::Error, 'Language not found!') + end + end + + context 'when customer creation fails with a server error' do + before do + WebMock + .stub_request(:post, telecom_center_api_endpoint) + .to_return(status: 500, body: nil) + end + + it 'raises an unknown error' do + expect { subject }.to raise_error(Billing::Provisioning::Errors::Error, 'Unknown error') + end + end + end + + describe '#before_destroy' do + subject { described_class.new(service).before_destroy } + + context 'when customer deletion is successful' do + before do + WebMock + .stub_request(:delete, "#{telecom_center_api_endpoint}/#{service.id}") + .to_return(status: 204) + end + + it 'sends a DELETE request to delete the customer' do + subject + expect(WebMock).to have_requested(:delete, "#{telecom_center_api_endpoint}/#{service.id}").once + end + end + + context 'when customer deletion fails' do + let(:error_body) { { errors: [{ title: 'Validation error', detail: 'Validation error' }] } } + + before do + WebMock + .stub_request(:delete, "#{telecom_center_api_endpoint}/#{service.id}") + .to_return(status: 422, body: error_body.to_json) + end + + it 'raises a validation error' do + expect { subject }.to raise_error(Billing::Provisioning::Errors::Error) + end + end + end + + describe '#verify_service_variables!' do + subject { described_class.new(service).verify_service_variables! } + + context 'with valid attributes' do + it 'returns the service variables' do + expect(subject).to eq(service.variables) + end + end + + context 'when attributes are partially provided' do + let(:service_attrs) { { type: service_type, variables: { attributes: { name: 'Service name' } } } } + + it 'validates and returns the partial attributes' do + expect(subject).to eq('attributes' => { 'name' => 'Service name' }) + end + end + + context 'when attributes: { name: nil }' do + let(:service_attrs) { { type: service_type, variables: { attributes: { name: nil } } } } + let(:err_msg) { 'Validation error: attributes.name - must be a string' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + + context 'when attributes: nil' do + let(:service_attrs) { { type: service_type, variables: { attributes: nil } } } + let(:err_msg) { 'Validation error: .attributes - must be a hash' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + + context 'when attributes: ""' do + let(:service_attrs) { { type: service_type, variables: { attributes: '' } } } + let(:err_msg) { 'Validation error: .attributes - must be a hash' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + end + + describe '.verify_service_type_variables!' do + subject { described_class.verify_service_type_variables!(service_type) } + + context 'with valid data' do + let(:service_type_attrs) do + super().deep_merge(variables: { attributes: { name: 'Test Service', language: 'EN', trm_mode: 'AUTO', capacity_limit: 100, sip_account_limit: 10 } }) + end + + it 'returns the service type variables' do + expect(subject).to eq(service_type.variables) + end + end + + context 'when name attribute is provided only' do + let(:service_type_attrs) { super().deep_merge(variables: { attributes: { name: 'Test Service' } }) } + + it 'validates and returns the service type variables' do + expect(subject).to eq service_type.variables + end + end + + context 'when attributes: {}' do + let(:service_type_attrs) { super().deep_merge variables: { attributes: {} } } + let(:err_msg) { 'Validation error: .attributes - must be filled' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + + context 'when attributes: { name: '' }' do + let(:service_type_attrs) { super().deep_merge variables: { attributes: { name: '' } } } + let(:err_msg) { 'Validation error: attributes.name - must be filled' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + + context 'when attributes: { name: nil }' do + let(:service_type_attrs) { super().deep_merge variables: { attributes: { name: nil } } } + let(:err_msg) { 'Validation error: attributes.name - must be a string' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + + context 'when attributes: nil' do + let(:service_type_attrs) { super().deep_merge variables: { attributes: nil } } + let(:err_msg) { 'Validation error: .attributes - must be a hash' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + + context 'when attributes: ""' do + let(:service_type_attrs) { super().deep_merge variables: { attributes: '' } } + let(:err_msg) { 'Validation error: .attributes - must be a hash' } + + it 'should raise error' do + expect { subject }.to raise_error Billing::Provisioning::Errors::InvalidVariablesError, err_msg + end + end + end +end diff --git a/spec/models/billing/service_type_spec.rb b/spec/models/billing/service_type_spec.rb index 779824c82..fc7b1e295 100644 --- a/spec/models/billing/service_type_spec.rb +++ b/spec/models/billing/service_type_spec.rb @@ -152,5 +152,45 @@ end end end + + context 'with provisioning_class=Billing::Provisioning::PhoneSystems' do + context 'when invalid data' do + let(:create_params) { super().merge(provisioning_class: 'Billing::Provisioning::PhoneSystems') } + + include_examples :does_not_create_record, errors: { + variables: [ + '.endpoint - is missing', + '.username - is missing', + '.password - is missing', + '.attributes - is missing' + ] + } + end + + context 'when valid data' do + let(:create_params) do + { + name: 'test', + provisioning_class: 'Billing::Provisioning::PhoneSystems', + variables: { + 'endpoint' => 'https://api.telecom.center', + 'username' => 'test', + 'password' => 'test', + 'attributes' => { + 'name' => 'John Johnson', + 'language' => 'EN', + 'trm_mode' => 'operator', + 'capacity_limit' => 10, + 'sip_account_limit' => 5 + } + } + } + end + + it_behaves_like :creates_record do + let(:expected_record_attrs) { expected_attrs } + end + end + end end end