1 package FS::part_export::voip_ms;
3 use base qw( FS::part_export );
11 use HTTP::Request::Common;
14 our $me = '[voip.ms]';
16 our $base_url = 'https://voip.ms/api/v1/rest.php';
18 # cache cities and provinces
19 our $CACHE; # a FileCache; their API is not as quick as I'd like
20 our $cache_timeout = 86400; # seconds
22 tie my %options, 'Tie::IxHash',
23 'account' => { label => 'Main account ID' },
24 'username' => { label => 'API username', },
25 'password' => { label => 'API password', },
26 'debug' => { label => 'Enable debugging', type => 'checkbox', value => 1 },
27 # could dynamically pull this from the API...
32 option_labels => { 1 => 'SIP', 3 => 'IAX' },
35 label => 'Authorization type',
38 option_labels => { 1 => 'User/Password', 2 => 'Static IP' },
41 label => 'DID billing mode',
44 option_labels => { 1 => 'Per minute', 2 => 'Flat rate' },
47 label => 'Device type',
50 option_labels => { 1 => 'IP PBX, e.g. Asterisk',
51 2 => 'IP phone or softphone',
55 label => 'Canada routing policy',
58 option_labels => { 1 => 'Value (lowest price)',
59 2 => 'Premium (highest quality)'
62 'international_route' => { # yes, 'route'
63 label => 'International routing policy',
65 options => [ 0, 1, 2 ],
66 option_labels => { 0 => 'Disable international calls',
67 1 => 'Value (lowest price)',
68 2 => 'Premium (highest quality)'
72 label => 'Enable CNAM lookup on incoming calls',
78 tie my %roles, 'Tie::IxHash',
79 'subacct' => { label => 'SIP client',
82 'did' => { label => 'DID',
89 'svc' => [qw( svc_acct svc_phone )],
91 'Provision subaccounts and DIDs to voip.ms wholesale',
92 'options' => \%options,
96 <P>Export to <b>voip.ms</b> hosted PBX service.</P>
97 <P>This requires two service definitions to be configured on the same package:
99 <LI>An account service for the subaccount (the "login" used by the
100 customer's PBX or IP phone, and the call routing service). This should
101 be attached to the export in the "subacct" role. If you are using
102 password authentication, the <i>username</i> and <i>_password</i> will
103 be used to authenticate to voip.ms. If you are using static IP
104 authentication, the <i>slipip</I> (IP address) field should be set to
106 <LI>A phone service for a DID, attached to the export in the DID role.
107 You must select a server for the "SIP Host" field. Calls from this DID
108 will be routed to the customer via that server.</LI>
113 <LI>Main account ID: the numeric ID for the master account.
114 Subaccount usernames will be prefixed with this number and an underscore,
115 so if you create a subaccount in Freeside with a username of "myuser",
116 the SIP device will have to authenticate as something like
117 "123456_myuser".</LI>
118 <LI>API username/password: your API login; see
119 <a href="https://www.voip.ms/m/api.php">this page</a> to configure it
120 if you haven't done so yet.</LI>
121 <LI>Enable debugging: writes all traffic with the API server to the log.
122 This includes passwords.</LI>
124 The other options correspond to options in either the subaccount or DID
125 configuration menu in the voip.ms portal; see documentation there for
132 my($self, $svc_x) = (shift, shift);
134 my $role = $self->svc_role($svc_x);
135 if ( $role eq 'subacct' ) {
137 my $error = $self->insert_subacct($svc_x);
138 return "$me $error" if $error;
140 my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
142 foreach my $svc_phone (@existing_dids) {
143 $error = $self->insert_did($svc_phone, $svc_x);
144 return "$me $error ordering DID ".$svc_phone->phonenum
148 } elsif ( $role eq 'did' ) {
150 my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
151 return if !$svc_acct;
153 my $error = $self->insert_did($svc_x, $svc_acct);
154 return "$me $error" if $error;
161 my ($self, $svc_new, $svc_old) = @_;
162 my $role = $self->svc_role($svc_new);
164 if ( $role eq 'subacct' ) {
165 $error = $self->replace_subacct($svc_new, $svc_old);
166 } elsif ( $role eq 'did' ) {
167 $error = $self->replace_did($svc_new, $svc_old);
169 return "$me $error" if $error;
174 my ($self, $svc_x) = (shift, shift);
175 my $role = $self->svc_role($svc_x);
176 if ( $role eq 'subacct' ) {
178 my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
181 foreach my $svc_phone (@existing_dids) {
182 $error = $self->delete_did($svc_phone);
183 return "$me $error canceling DID ".$svc_phone->phonenum
187 $error = $self->delete_subacct($svc_x);
188 return "$me $error" if $error;
190 } elsif ( $role eq 'did' ) {
192 my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
193 return if !$svc_acct;
195 my $error = $self->delete_did($svc_x);
196 return "$me $error" if $error;
205 my $role = $self->svc_role($svc_x);
206 return if $role ne 'subacct'; # can't suspend DIDs directly
208 my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
209 return "$me $error" if $error;
213 sub export_unsuspend {
216 my $role = $self->svc_role($svc_x);
217 return if $role ne 'subacct'; # can't suspend DIDs directly
219 $svc_x->set('unsuspended', 1); # hack to tell replace_subacct to do it
220 my $error = $self->replace_subacct($svc_x, $svc_x); #same
221 return "$me $error" if $error;
227 my ($self, $svc_acct) = @_;
228 my $method = 'createSubAccount';
229 my $content = $self->subacct_content($svc_acct);
231 my $result = $self->api_request($method, $content);
232 if ( $result->{status} ne 'success' ) {
233 return $result->{status}; # or look up the error message string?
236 # result includes the account ID and the full username, but we don't
237 # really need to keep those; we can look them up later
242 my ($self, $svc_phone, $svc_acct) = @_;
243 my $method = 'orderDID';
244 my $content = $self->did_content($svc_phone, $svc_acct);
245 my $result = $self->api_request($method, $content);
246 if ( $result->{status} ne 'success' ) {
247 return $result->{status}; # or look up the error message string?
253 my ($self, $svc_acct) = @_;
254 my $account = $self->option('account') . '_' . $svc_acct->username;
256 my $id = $self->subacct_id($svc_acct);
259 return $id; # it's an error
261 } elsif ( $id eq '' ) {
263 return ''; # account doesn't exist, don't need to delete
265 } # else it's numeric
267 warn "$me deleting account $account with ID $id\n" if $DEBUG;
268 my $result = $self->api_request('delSubAccount', { id => $id });
269 if ( $result->{status} ne 'success' ) {
270 return $result->{status};
276 my ($self, $svc_phone) = @_;
277 my $phonenum = $svc_phone->phonenum;
279 my $result = $self->api_request('cancelDID', { did => $phonenum });
280 if ( $result->{status} ne 'success' and $result->{status} ne 'invalid_did' )
282 return $result->{status};
287 sub replace_subacct {
288 my ($self, $svc_new, $svc_old) = @_;
289 if ( $svc_new->username ne $svc_old->username ) {
290 return "can't change account username; delete and recreate the account instead";
293 my $id = $self->subacct_id($svc_new);
298 } elsif ( $id eq '' ) {
300 # account doesn't exist; provision it anew
301 return $self->insert_subacct($svc_new);
305 my $content = $self->subacct_content($svc_new);
306 delete $content->{username};
307 $content->{id} = $id;
309 my $result = $self->api_request('setSubAccount', $content);
310 if ( $result->{status} ne 'success' ) {
311 return $result->{status};
318 my ($self, $svc_new, $svc_old) = @_;
319 if ( $svc_new->phonenum ne $svc_old->phonenum ) {
320 return "can't change DID phone number";
322 # check that there's a subacct set up
323 my $svc_acct = $self->svc_with_role($svc_new, 'subacct')
326 # check for the existing DID
327 my $result = $self->api_request('getDIDsInfo',
328 { did => $svc_new->phonenum }
330 if ( $result->{status} eq 'invalid_did' ) {
333 return $self->insert_did($svc_new, $svc_acct);
335 } elsif ( $result->{status} ne 'success' ) {
337 return $result->{status};
341 my $existing = $result->{dids}[0];
343 my $content = $self->did_content($svc_new, $svc_acct);
344 if ( $content->{billing_type} == $existing->{billing_type} ) {
345 delete $content->{billing_type}; # confuses the server otherwise
347 $result = $self->api_request('setDIDInfo', $content);
348 if ( $result->{status} ne 'success' ) {
349 return $result->{status};
355 #######################
356 # CONVENIENCE METHODS #
357 #######################
360 my ($self, $svc_acct) = @_;
361 my $account = $self->option('account') . '_' . $svc_acct->username;
363 # look up the subaccount's numeric ID
364 my $result = $self->api_request('getSubAccounts', { account => $account });
365 if ( $result->{status} eq 'invalid_account' ) {
367 } elsif ( $result->{status} ne 'success' ) {
368 return "$result->{status} looking up account ID";
370 return $result->{accounts}[0]{id};
374 sub subacct_content {
375 my ($self, $svc_acct) = @_;
377 my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
379 my $desc = $svc_acct->finger || $svc_acct->username;
380 my $intl = $self->option('international_route');
383 $intl = 1; # can't send zero
388 if ( $cust_pkg and $cust_pkg->susp > 0 and !$svc_acct->get('unsuspended') ) {
389 # we can't explicitly suspend their account, so just set its password to
390 # a partially random string that satisfies the password rules
391 # (we still have their real password in the svc_acct record)
392 %auth = ( auth_type => 1,
393 password => sprintf('Suspend-%08d', int(rand(100000000)) ),
396 %auth = ( auth_type => $self->option('auth_type'),
397 password => $svc_acct->_password,
398 ip => $svc_acct->slipip,
402 username => $svc_acct->username,
403 description => $desc,
405 device_type => $self->option('device_type'),
406 canada_routing => $self->option('canada_routing'),
407 lock_international => $lockintl,
408 international_route => $intl,
409 # sensible defaults for these
410 music_on_hold => 'default', # silence
411 allowed_codecs => 'ulaw;g729;gsm',
418 my ($self, $svc_phone, $svc_acct) = @_;
420 my $account = $self->option('account') . '_' . $svc_acct->username;
421 my $phonenum = $svc_phone->phonenum;
422 # look up POP number (for some reason this is assigned per DID...)
423 my $sip_server = $svc_phone->sip_server
424 or return "SIP server required";
425 my $popnum = $self->cache('server_popnum')->{ $svc_phone->sip_server }
426 or return "SIP server '$sip_server' is unknown";
429 routing => "account:$account",
430 # secondary routing options (failovers, voicemail) are outside our
432 # though we could support them using the "forwarddst" field?
434 dialtime => 60, # sensible default, add an option if needed
435 cnam => ($self->option('cnam_lookup') ? 1 : 0),
436 note => $svc_phone->phone_name,
437 billing_type => $self->option('billing_type'),
445 sub get_dids_npa_select { 0 } # all Canadian VoIP providers seem to have this
451 my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
453 if ( $opt{'region'} ) {
455 # return numbers (probably shouldn't cache this)
456 my ($ratecenter, $province) = $opt{'region'} =~ /^(.*), (..)$/;
457 my $country = $self->cache('province_country')->{ $province };
459 if ( $country eq 'CAN' ) {
460 $result = $self->api_insist('getDIDsCAN',
461 { province => $province,
462 ratecenter => $ratecenter
465 } elsif ( $country eq 'USA' ) {
466 $result = $self->api_insist('getDIDsUSA',
467 { state => $province,
468 ratecenter => $ratecenter
472 my @return = map { $_->{did} } @{ $result->{dids} };
476 if ( $opt{'state'} ) {
477 my $province = $opt{'state'};
479 # cache() will refresh the cache if necessary, and die on failure.
480 # default here is only in case someone gives us a state that
482 return $self->cache('province_city', $province) || [];
486 # return a list of provinces
488 @{ $self->cache('country_province')->{CAN} },
489 @{ $self->cache('country_province')->{USA} },
495 sub get_sip_servers {
497 return [ sort keys %{ $self->cache('server_popnum') } ];
502 my $element = shift or return;
503 my $province = shift;
505 $CACHE ||= Cache::FileCache->new({
506 'cache_root' => $FS::UID::cache_dir.'/cache'.$FS::UID::datasrc,
507 'namespace' => __PACKAGE__,
508 'default_expires_in' => $cache_timeout,
511 if ( $element eq 'province_city' ) {
512 $element .= ".$province";
514 return $CACHE->get($element) || $self->reload_cache($element);
520 if ( $element eq 'province_country' or $element eq 'country_province' ) {
521 # populate provinces/states
523 my %province_country;
524 my %country_province = ( CAN => [], USA => [] );
526 my $result = $self->api_insist('getProvinces');
527 foreach my $province (map { $_->{province} } @{ $result->{provinces} }) {
528 $province_country{$province} = 'CAN';
529 push @{ $country_province{CAN} }, $province;
532 $result = $self->api_insist('getStates');
533 foreach my $state (map { $_->{state} } @{ $result->{states} }) {
534 $province_country{$state} = 'USA';
535 push @{ $country_province{USA} }, $state;
538 $CACHE->set('province_country', \%province_country);
539 $CACHE->set('country_province', \%country_province);
540 return $CACHE->get($element);
542 } elsif ( $element eq 'server_popnum' ) {
544 my $result = $self->api_insist('getServersInfo');
546 foreach (@{ $result->{servers} }) {
547 $server_popnum{ $_->{server_hostname} } = $_->{server_pop};
550 $CACHE->set('server_popnum', \%server_popnum);
551 return \%server_popnum;
553 } elsif ( $element =~ /^province_city\.(\w+)$/ ) {
557 # then get the ratecenters for that province
558 my $country = $self->cache('province_country')->{$province};
561 if ( $country eq 'CAN' ) {
563 my $result = $self->api_insist('getRateCentersCAN',
564 { province => $province });
566 foreach (@{ $result->{ratecenters} }) {
567 my $ratecenter = $_->{ratecenter} . ", $province"; # disambiguate
568 push @ratecenters, $ratecenter;
571 } elsif ( $country eq 'USA' ) {
573 my $result = $self->api_insist('getRateCentersUSA',
574 { state => $province });
575 foreach (@{ $result->{ratecenters} }) {
576 my $ratecenter = $_->{ratecenter} . ", $province";
577 push @ratecenters, $ratecenter;
582 $CACHE->set($element, \@ratecenters);
583 return \@ratecenters;
594 =item api_request METHOD, CONTENT
596 Makes a REST request with method name METHOD, and POST content CONTENT (as
603 my ($method, $content) = @_;
604 $DEBUG ||= 1 if $self->option('debug');
605 my $url = URI->new($base_url);
608 'api_username' => $self->option('username'),
609 'api_password' => $self->option('password'),
613 my $request = GET($url,
614 'Accept' => 'text/json',
617 warn "$me $method\n" . $request->as_string ."\n" if $DEBUG;
618 my $ua = LWP::UserAgent->new;
619 my $response = $ua->request($request);
620 warn "$me received\n" . $response->as_string ."\n" if $DEBUG;
621 if ( !$response->is_success ) {
622 return { status => $response->content };
625 return decode_json($response->content);
628 =item api_insist METHOD, CONTENT
630 Exactly like L</api_request>, but if the returned "status" is not "success",
638 my $result = $self->api_request(@_);
639 if ( $result->{status} eq 'success' ) {
641 } elsif ( $result->{status} ) {
642 die "$me $method: $result->{status}\n";
644 die "$me $method: no status returned\n";