summaryrefslogtreecommitdiff
path: root/FS
diff options
context:
space:
mode:
authorMark Wells <mark@freeside.biz>2014-12-28 23:27:48 -0800
committerMark Wells <mark@freeside.biz>2014-12-28 23:27:48 -0800
commite3503e19a5a6c876f410903a3946dd9f1597aa46 (patch)
tree6d361bb09fd1aa0f1d9b9578198a913a0950e955 /FS
parentaf96802d080094c381dd06b74488adfb20396574 (diff)
voip.ms export, #31834
Diffstat (limited to 'FS')
-rw-r--r--FS/FS/Schema.pm1
-rw-r--r--FS/FS/part_export.pm72
-rw-r--r--FS/FS/part_export/voip_ms.pm648
-rw-r--r--FS/FS/svc_phone.pm14
4 files changed, 718 insertions, 17 deletions
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 8b362a7..f559564 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -5656,6 +5656,7 @@ sub tables_hashref {
'e911_class', 'char', 'NULL', 1, '', '',
'e911_type', 'char', 'NULL', 1, '', '',
'circuit_svcnum', 'int', 'NULL', '', '', '',
+ 'sip_server', 'varchar', 'NULL', $char_d, '', '',
],
'primary_key' => 'svcnum',
'unique' => [ [ 'sms_carrierid', 'sms_account'] ],
diff --git a/FS/FS/part_export.pm b/FS/FS/part_export.pm
index 7819a7c..f3d9774 100644
--- a/FS/FS/part_export.pm
+++ b/FS/FS/part_export.pm
@@ -535,23 +535,6 @@ sub default_export_machine {
die "no default export hostname for export ".$self->exportnum;
}
-=item svc_role SVC_X
-
-Returns the role that SVC_X occupies with respect to this export, if any.
-This is part of the part_svc's export configuration.
-
-=cut
-
-sub svc_role {
- my $self = shift;
- my $svc_x = shift;
- my $cust_svc = $svc_x->cust_svc or return '';
- my $export_svc = qsearchs('export_svc', { exportnum => $self->exportnum,
- svcpart => $cust_svc->svcpart })
- or return '';
- $export_svc->role;
-}
-
#these should probably all go away, just let the subclasses define em
=item export_insert SVC_OBJECT
@@ -753,6 +736,61 @@ sub get_dids_npa_select { 1; }
# change the phone number for a service. if false, then they can't (have to
# reprovision completely).
+=item svc_role SVC
+
+Returns the role that SVC occupies with respect to this export, if any.
+This is part of the part_svc's export configuration.
+
+=cut
+
+sub svc_role {
+ my $self = shift;
+ my $svc_x = shift;
+ my $cust_svc = $svc_x->cust_svc or return '';
+ my $export_svc = qsearchs('export_svc', { exportnum => $self->exportnum,
+ svcpart => $cust_svc->svcpart })
+ or return '';
+ $export_svc->role;
+}
+
+=item svc_with_role { SVC | PKGNUM }, ROLE
+
+Given a svc_* object SVC or pkgnum PKG, and a role name ROLE, finds the
+service(s) in the same package that are linked to this export with ROLE.
+
+=cut
+
+sub svc_with_role {
+ my $self = shift;
+ my $svc_or_pkgnum = shift;
+ my $role = shift;
+ my $pkgnum;
+ if ( ref $svc_or_pkgnum ) {
+ $pkgnum = $svc_or_pkgnum->cust_svc->pkgnum or return '';
+ } else {
+ $pkgnum = $svc_or_pkgnum;
+ }
+ my $role_info = $self->info->{roles}->{$role}
+ or die "role '$role' does not exist for export '".$self->exporttype."'\n";
+ my $svcdb = $role_info->{svcdb};
+
+ my @svcs = qsearch({
+ 'table' => $svcdb,
+ 'addl_from' => ' JOIN cust_svc USING (svcnum)' .
+ ' JOIN export_svc USING (svcpart)',
+ 'extra_sql' => " WHERE cust_svc.pkgnum = $pkgnum" .
+ " AND export_svc.exportnum = ".$self->exportnum .
+ " AND export_svc.role = '$role'",
+ });
+ if ( $role_info->{multiple} ) {
+ return @svcs;
+ } else {
+ if ( @svcs > 1 ) {
+ warn "multiple $role services in pkgnum $pkgnum; returning the first one.\n";
+ }
+ return $svcs[0];
+ }
+}
=back
diff --git a/FS/FS/part_export/voip_ms.pm b/FS/FS/part_export/voip_ms.pm
new file mode 100644
index 0000000..44ce908
--- /dev/null
+++ b/FS/FS/part_export/voip_ms.pm
@@ -0,0 +1,648 @@
+package FS::part_export::voip_ms;
+
+use base qw( FS::part_export );
+use strict;
+
+use Tie::IxHash;
+use LWP::UserAgent;
+use URI;
+use URI::Escape;
+use JSON;
+use HTTP::Request::Common;
+use Cache::FileCache;
+
+our $me = '[voip.ms]';
+our $DEBUG = 2;
+our $base_url = 'https://voip.ms/api/v1/rest.php';
+
+# cache cities and provinces
+our $CACHE; # a FileCache; their API is not as quick as I'd like
+our $cache_timeout = 86400; # seconds
+
+tie my %options, 'Tie::IxHash',
+ 'account' => { label => 'Main account ID' },
+ 'username' => { label => 'API username', },
+ 'password' => { label => 'API password', },
+ 'debug' => { label => 'Enable debugging', type => 'checkbox', value => 1 },
+ # could dynamically pull this from the API...
+ 'protocol' => {
+ label => 'Protocol',
+ type => 'select',
+ options => [ 1, 3 ],
+ option_labels => { 1 => 'SIP', 3 => 'IAX' },
+ },
+ 'auth_type' => {
+ label => 'Authorization type',
+ type => 'select',
+ options => [ 1, 2 ],
+ option_labels => { 1 => 'User/Password', 2 => 'Static IP' },
+ },
+ 'billing_type' => {
+ label => 'DID billing mode',
+ type => 'select',
+ options => [ 1, 2 ],
+ option_labels => { 1 => 'Per minute', 2 => 'Flat rate' },
+ },
+ 'device_type' => {
+ label => 'Device type',
+ type => 'select',
+ options => [ 1, 2 ],
+ option_labels => { 1 => 'IP PBX, e.g. Asterisk',
+ 2 => 'IP phone or softphone',
+ },
+ },
+ 'canada_routing' => {
+ label => 'Canada routing policy',
+ type => 'select',
+ options => [ 1, 2 ],
+ option_labels => { 1 => 'Value (lowest price)',
+ 2 => 'Premium (highest quality)'
+ },
+ },
+ 'international_route' => { # yes, 'route'
+ label => 'International routing policy',
+ type => 'select',
+ options => [ 0, 1, 2 ],
+ option_labels => { 0 => 'Disable international calls',
+ 1 => 'Value (lowest price)',
+ 2 => 'Premium (highest quality)'
+ },
+ },
+ 'cnam_lookup' => {
+ label => 'Enable CNAM lookup on incoming calls',
+ type => 'checkbox',
+ },
+
+;
+
+tie my %roles, 'Tie::IxHash',
+ 'subacct' => { label => 'SIP client',
+ svcdb => 'svc_acct',
+ },
+ 'did' => { label => 'DID',
+ svcdb => 'svc_phone',
+ multiple => 1,
+ },
+;
+
+our %info = (
+ 'svc' => [qw( svc_acct svc_phone )],
+ 'desc' =>
+ 'Provision subaccounts and DIDs to voip.ms wholesale',
+ 'options' => \%options,
+ 'roles' => \%roles,
+ 'no_machine' => 1,
+ 'notes' => <<'END'
+<P>Export to <b>voip.ms</b> hosted PBX service.</P>
+<P>This requires two service definitions to be configured on the same package:
+ <OL>
+ <LI>An account service for the subaccount (the "login" used by the
+ customer's PBX or IP phone, and the call routing service). This should
+ be attached to the export in the "subacct" role. If you are using
+ password authentication, the <i>username</i> and <i>_password</i> will
+ be used to authenticate to voip.ms. If you are using static IP
+ authentication, the <i>slipip</I> (IP address) field should be set to
+ the address.</LI>
+ <LI>A phone service for a DID, attached to the export in the DID role.
+ You must select a server for the "SIP Host" field. Calls from this DID
+ will be routed to the customer via that server.</LI>
+ </OL>
+</P>
+<P>Export options:
+ <UL>
+ <LI>Main account ID: the numeric ID for the master account.
+ Subaccount usernames will be prefixed with this number and an underscore,
+ so if you create a subaccount in Freeside with a username of "myuser",
+ the SIP device will have to authenticate as something like
+ "123456_myuser".</LI>
+ <LI>API username/password: your API login; see
+ <a href="https://www.voip.ms/m/api.php">this page</a> to configure it
+ if you haven't done so yet.</LI>
+ <LI>Enable debugging: writes all traffic with the API server to the log.
+ This includes passwords.</LI>
+ </UL>
+ The other options correspond to options in either the subaccount or DID
+ configuration menu in the voip.ms portal; see documentation there for
+ details.
+</P>
+END
+);
+
+sub export_insert {
+ my($self, $svc_x) = (shift, shift);
+
+ my $role = $self->svc_role($svc_x);
+ if ( $role eq 'subacct' ) {
+
+ my $error = $self->insert_subacct($svc_x);
+ return "$me $error" if $error;
+
+ my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
+
+ foreach my $svc_phone (@existing_dids) {
+ $error = $self->insert_did($svc_phone, $svc_x);
+ return "$me $error ordering DID ".$svc_phone->phonenum
+ if $error;
+ }
+
+ } elsif ( $role eq 'did' ) {
+
+ my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
+ return if !$svc_acct;
+
+ my $error = $self->insert_did($svc_x, $svc_acct);
+ return "$me $error" if $error;
+
+ }
+ '';
+}
+
+sub export_replace {
+ my ($self, $svc_new, $svc_old) = @_;
+ my $role = $self->svc_role($svc_new);
+ my $error;
+ if ( $role eq 'subacct' ) {
+ $error = $self->replace_subacct($svc_new, $svc_old);
+ } elsif ( $role eq 'did' ) {
+ $error = $self->replace_did($svc_new, $svc_old);
+ }
+ return "$me $error" if $error;
+ '';
+}
+
+sub export_delete {
+ my ($self, $svc_x) = (shift, shift);
+ my $role = $self->svc_role($svc_x);
+ if ( $role eq 'subacct' ) {
+
+ my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
+
+ my $error;
+ foreach my $svc_phone (@existing_dids) {
+ $error = $self->delete_did($svc_phone);
+ return "$me $error canceling DID ".$svc_phone->phonenum
+ if $error;
+ }
+
+ $error = $self->delete_subacct($svc_x);
+ return "$me $error" if $error;
+
+ } elsif ( $role eq 'did' ) {
+
+ my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
+ return if !$svc_acct;
+
+ my $error = $self->delete_did($svc_x);
+ return "$me $error" if $error;
+
+ }
+ '';
+}
+
+sub export_suspend {
+ my $self = shift;
+ my $svc_x = shift;
+ my $role = $self->svc_role($svc_x);
+ return if $role ne 'subacct'; # can't suspend DIDs directly
+
+ my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
+ return "$me $error" if $error;
+ '';
+}
+
+sub export_unsuspend {
+ my $self = shift;
+ my $svc_x = shift;
+ my $role = $self->svc_role($svc_x);
+ return if $role ne 'subacct'; # can't suspend DIDs directly
+
+ $svc_x->set('unsuspended', 1); # hack to tell replace_subacct to do it
+ my $error = $self->replace_subacct($svc_x, $svc_x); #same
+ return "$me $error" if $error;
+ '';
+}
+
+
+sub insert_subacct {
+ my ($self, $svc_acct) = @_;
+ my $method = 'createSubAccount';
+ my $content = $self->subacct_content($svc_acct);
+
+ my $result = $self->api_request($method, $content);
+ if ( $result->{status} ne 'success' ) {
+ return $result->{status}; # or look up the error message string?
+ }
+
+ # result includes the account ID and the full username, but we don't
+ # really need to keep those; we can look them up later
+ '';
+}
+
+sub insert_did {
+ my ($self, $svc_phone, $svc_acct) = @_;
+ my $method = 'orderDID';
+ my $content = $self->did_content($svc_phone, $svc_acct);
+ my $result = $self->api_request($method, $content);
+ if ( $result->{status} ne 'success' ) {
+ return $result->{status}; # or look up the error message string?
+ }
+ '';
+}
+
+sub delete_subacct {
+ my ($self, $svc_acct) = @_;
+ my $account = $self->option('account') . '_' . $svc_acct->username;
+
+ my $id = $self->subacct_id($svc_acct);
+ if ( $id =~ /\D/ ) {
+
+ return $id; # it's an error
+
+ } elsif ( $id eq '' ) {
+
+ return ''; # account doesn't exist, don't need to delete
+
+ } # else it's numeric
+
+ warn "$me deleting account $account with ID $id\n" if $DEBUG;
+ my $result = $self->api_request('delSubAccount', { id => $id });
+ if ( $result->{status} ne 'success' ) {
+ return $result->{status};
+ }
+ '';
+}
+
+sub delete_did {
+ my ($self, $svc_phone) = @_;
+ my $phonenum = $svc_phone->phonenum;
+
+ my $result = $self->api_request('cancelDID', { did => $phonenum });
+ if ( $result->{status} ne 'success' and $result->{status} ne 'invalid_did' )
+ {
+ return $result->{status};
+ }
+ '';
+}
+
+sub replace_subacct {
+ my ($self, $svc_new, $svc_old) = @_;
+ if ( $svc_new->username ne $svc_old->username ) {
+ return "can't change account username; delete and recreate the account instead";
+ }
+
+ my $id = $self->subacct_id($svc_new);
+ if ( $id =~ /\D/ ) {
+
+ return $id;
+
+ } elsif ( $id eq '' ) {
+
+ # account doesn't exist; provision it anew
+ return $self->insert_subacct($svc_new);
+
+ }
+
+ my $content = $self->subacct_content($svc_new);
+ delete $content->{username};
+ $content->{id} = $id;
+
+ my $result = $self->api_request('setSubAccount', $content);
+ if ( $result->{status} ne 'success' ) {
+ return $result->{status};
+ }
+
+ '';
+}
+
+sub replace_did {
+ my ($self, $svc_new, $svc_old) = @_;
+ if ( $svc_new->phonenum ne $svc_old->phonenum ) {
+ return "can't change DID phone number";
+ }
+ # check that there's a subacct set up
+ my $svc_acct = $self->svc_with_role($svc_new, 'subacct')
+ or return '';
+
+ # check for the existing DID
+ my $result = $self->api_request('getDIDsInfo',
+ { did => $svc_new->phonenum }
+ );
+ if ( $result->{status} eq 'invalid_did' ) {
+
+ # provision the DID
+ return $self->insert_did($svc_new, $svc_acct);
+
+ } elsif ( $result->{status} ne 'success' ) {
+
+ return $result->{status};
+
+ }
+
+ my $existing = $result->{dids}[0];
+
+ my $content = $self->did_content($svc_new, $svc_acct);
+ if ( $content->{billing_type} == $existing->{billing_type} ) {
+ delete $content->{billing_type}; # confuses the server otherwise
+ }
+ $result = $self->api_request('setDIDInfo', $content);
+ if ( $result->{status} ne 'success' ) {
+ return $result->{status};
+ }
+
+ return '';
+}
+
+#######################
+# CONVENIENCE METHODS #
+#######################
+
+sub subacct_id {
+ my ($self, $svc_acct) = @_;
+ my $account = $self->option('account') . '_' . $svc_acct->username;
+
+ # look up the subaccount's numeric ID
+ my $result = $self->api_request('getSubAccounts', { account => $account });
+ if ( $result->{status} eq 'invalid_account' ) {
+ return '';
+ } elsif ( $result->{status} ne 'success' ) {
+ return "$result->{status} looking up account ID";
+ } else {
+ return $result->{accounts}[0]{id};
+ }
+}
+
+sub subacct_content {
+ my ($self, $svc_acct) = @_;
+
+ my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
+
+ my $desc = $svc_acct->finger || $svc_acct->username;
+ my $intl = $self->option('international_route');
+ my $lockintl = 0;
+ if ($intl == 0) {
+ $intl = 1; # can't send zero
+ $lockintl = 1;
+ }
+
+ my %auth;
+ if ( $cust_pkg and $cust_pkg->susp > 0 and !$svc_acct->get('unsuspended') ) {
+ # we can't explicitly suspend their account, so just set its password to
+ # a partially random string that satisfies the password rules
+ # (we still have their real password in the svc_acct record)
+ %auth = ( auth_type => 1,
+ password => sprintf('Suspend-%08d', int(rand(100000000)) ),
+ );
+ } else {
+ %auth = ( auth_type => $self->option('auth_type'),
+ password => $svc_acct->_password,
+ ip => $svc_acct->slipip,
+ );
+ }
+ return {
+ username => $svc_acct->username,
+ description => $desc,
+ %auth,
+ device_type => $self->option('device_type'),
+ canada_routing => $self->option('canada_routing'),
+ lock_international => $lockintl,
+ international_route => $intl,
+ # sensible defaults for these
+ music_on_hold => 'default', # silence
+ allowed_codecs => 'ulaw;g729;gsm',
+ dtmf_mode => 'AUTO',
+ nat => 'yes',
+ };
+}
+
+sub did_content {
+ my ($self, $svc_phone, $svc_acct) = @_;
+
+ my $account = $self->option('account') . '_' . $svc_acct->username;
+ my $phonenum = $svc_phone->phonenum;
+ # look up POP number (for some reason this is assigned per DID...)
+ my $sip_server = $svc_phone->sip_server
+ or return "SIP server required";
+ my $popnum = $self->cache('server_popnum')->{ $svc_phone->sip_server }
+ or return "SIP server '$sip_server' is unknown";
+ return {
+ did => $phonenum,
+ routing => "account:$account",
+ # secondary routing options (failovers, voicemail) are outside our
+ # scope here
+ # though we could support them using the "forwarddst" field?
+ pop => $popnum,
+ dialtime => 60, # sensible default, add an option if needed
+ cnam => ($self->option('cnam_lookup') ? 1 : 0),
+ note => $svc_phone->phone_name,
+ billing_type => $self->option('billing_type'),
+ };
+}
+
+#################
+# DID SELECTION #
+#################
+
+sub get_dids_npa_select { 0 } # all Canadian VoIP providers seem to have this
+
+sub get_dids {
+ my $self = shift;
+ my %opt = @_;
+
+ my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
+
+ if ( $opt{'region'} ) {
+
+ # return numbers (probably shouldn't cache this)
+ my ($ratecenter, $province) = $opt{'region'} =~ /^(.*), (..)$/;
+ my $country = $self->cache('province_country')->{ $province };
+ my $result;
+ if ( $country eq 'CAN' ) {
+ $result = $self->api_insist('getDIDsCAN',
+ { province => $province,
+ ratecenter => $ratecenter
+ }
+ );
+ } elsif ( $country eq 'USA' ) {
+ $result = $self->api_insist('getDIDsUSA',
+ { state => $province,
+ ratecenter => $ratecenter
+ }
+ );
+ }
+ my @return = map { $_->{did} } @{ $result->{dids} };
+ return \@return;
+ } else {
+
+ if ( $opt{'state'} ) {
+ my $province = $opt{'state'};
+
+ # cache() will refresh the cache if necessary, and die on failure.
+ # default here is only in case someone gives us a state that
+ # doesn't exist.
+ return $self->cache('province_city', $province) || [];
+
+ } else {
+
+ # return a list of provinces
+ return [
+ @{ $self->cache('country_province')->{CAN} },
+ @{ $self->cache('country_province')->{USA} },
+ ];
+ }
+ }
+}
+
+sub get_sip_servers {
+ my $self = shift;
+ return [ sort keys %{ $self->cache('server_popnum') } ];
+}
+
+sub cache {
+ my $self = shift;
+ my $element = shift or return;
+ my $province = shift;
+
+ $CACHE ||= Cache::FileCache->new({
+ 'cache_root' => $FS::UID::cache_dir.'/cache'.$FS::UID::datasrc,
+ 'namespace' => __PACKAGE__,
+ 'default_expires_in' => $cache_timeout,
+ });
+
+ if ( $element eq 'province_city' ) {
+ $element .= ".$province";
+ }
+ return $CACHE->get($element) || $self->reload_cache($element);
+}
+
+sub reload_cache {
+ my $self = shift;
+ my $element = shift;
+ if ( $element eq 'province_country' or $element eq 'country_province' ) {
+ # populate provinces/states
+
+ my %province_country;
+ my %country_province = ( CAN => [], USA => [] );
+
+ my $result = $self->api_insist('getProvinces');
+ foreach my $province (map { $_->{province} } @{ $result->{provinces} }) {
+ $province_country{$province} = 'CAN';
+ push @{ $country_province{CAN} }, $province;
+ }
+
+ $result = $self->api_insist('getStates');
+ foreach my $state (map { $_->{state} } @{ $result->{states} }) {
+ $province_country{$state} = 'USA';
+ push @{ $country_province{USA} }, $state;
+ }
+
+ $CACHE->set('province_country', \%province_country);
+ $CACHE->set('country_province', \%country_province);
+ return $CACHE->get($element);
+
+ } elsif ( $element eq 'server_popnum' ) {
+
+ my $result = $self->api_insist('getServersInfo');
+ my %server_popnum;
+ foreach (@{ $result->{servers} }) {
+ $server_popnum{ $_->{server_hostname} } = $_->{server_pop};
+ }
+
+ $CACHE->set('server_popnum', \%server_popnum);
+ return \%server_popnum;
+
+ } elsif ( $element =~ /^province_city\.(\w+)$/ ) {
+
+ my $province = $1;
+
+ # then get the ratecenters for that province
+ my $country = $self->cache('province_country')->{$province};
+ my @ratecenters;
+
+ if ( $country eq 'CAN' ) {
+
+ my $result = $self->api_insist('getRateCentersCAN',
+ { province => $province });
+
+ foreach (@{ $result->{ratecenters} }) {
+ my $ratecenter = $_->{ratecenter} . ", $province"; # disambiguate
+ push @ratecenters, $ratecenter;
+ }
+
+ } elsif ( $country eq 'USA' ) {
+
+ my $result = $self->api_insist('getRateCentersUSA',
+ { state => $province });
+ foreach (@{ $result->{ratecenters} }) {
+ my $ratecenter = $_->{ratecenter} . ", $province";
+ push @ratecenters, $ratecenter;
+ }
+
+ }
+
+ $CACHE->set($element, \@ratecenters);
+ return \@ratecenters;
+
+ } else {
+ return;
+ }
+}
+
+##############
+# API ACCESS #
+##############
+
+=item api_request METHOD, CONTENT
+
+Makes a REST request with method name METHOD, and POST content CONTENT (as
+a hashref).
+
+=cut
+
+sub api_request {
+ my $self = shift;
+ my ($method, $content) = @_;
+ $DEBUG ||= 1 if $self->option('debug');
+ my $url = URI->new($base_url);
+ $url->query_form(
+ 'method' => $method,
+ 'api_username' => $self->option('username'),
+ 'api_password' => $self->option('password'),
+ %$content
+ );
+
+ my $request = GET($url,
+ 'Accept' => 'text/json',
+ );
+
+ warn "$me $method\n" . $request->as_string ."\n" if $DEBUG;
+ my $ua = LWP::UserAgent->new;
+ my $response = $ua->request($request);
+ warn "$me received\n" . $response->as_string ."\n" if $DEBUG;
+ if ( !$response->is_success ) {
+ return { status => $response->content };
+ }
+
+ return decode_json($response->content);
+}
+
+=item api_insist METHOD, CONTENT
+
+Exactly like L</api_request>, but if the returned "status" is not "success",
+throws an exception.
+
+=cut
+
+sub api_insist {
+ my $self = shift;
+ my $method = $_[0];
+ my $result = $self->api_request(@_);
+ if ( $result->{status} eq 'success' ) {
+ return $result;
+ } elsif ( $result->{status} ) {
+ die "$me $method: $result->{status}\n";
+ } else {
+ die "$me $method: no status returned\n";
+ }
+}
+
+1;
diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm
index bd35cba..06ce948 100644
--- a/FS/FS/svc_phone.pm
+++ b/FS/FS/svc_phone.pm
@@ -136,6 +136,15 @@ Class of Service for E911 service (per the NENA 2.1 standard).
Type of Service for E911 service.
+=item circuit_svcnum
+
+The L<FS::svc_circuit> record for the physical circuit that transports this
+phone line.
+
+=item sip_server
+
+The hostname of the SIP server that this phone number is routed to.
+
=back
=head1 METHODS
@@ -253,6 +262,10 @@ sub table_info {
disable_inventory => 1,
multiple => 1,
},
+ 'sip_server' => {
+ label => 'SIP Host',
+ %dis2,
+ },
},
};
}
@@ -548,6 +561,7 @@ sub check {
'native', 'portin-reject', 'portout-reject'])
|| $self->ut_enumn('portable', ['','Y'])
|| $self->ut_textn('lnp_reject_reason')
+ || $self->ut_domainn('sip_server')
;
return $error if $error;