no reason for multi-select to be disabled on these
[freeside.git] / FS / FS / part_export / voip_ms.pm
1 package FS::part_export::voip_ms;
2
3 use base qw( FS::part_export );
4 use strict;
5
6 use Tie::IxHash;
7 use LWP::UserAgent;
8 use URI;
9 use URI::Escape;
10 use JSON;
11 use HTTP::Request::Common;
12 use Cache::FileCache;
13
14 our $me = '[voip.ms]';
15 our $DEBUG = 2;
16 our $base_url = 'https://voip.ms/api/v1/rest.php';
17
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
21
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...
28   'protocol'        => {
29     label             => 'Protocol',
30     type              => 'select',
31     options           => [ 1, 3 ],
32     option_labels     => { 1 => 'SIP', 3 => 'IAX' },
33   },
34   'auth_type'       => {
35     label             => 'Authorization type',
36     type              => 'select',
37     options           => [ 1, 2 ],
38     option_labels     => { 1 => 'User/Password', 2 => 'Static IP' },
39   },
40   'billing_type'    => {
41     label             => 'DID billing mode',
42     type              => 'select',
43     options           => [ 1, 2 ],
44     option_labels     => { 1 => 'Per minute', 2 => 'Flat rate' },
45   },
46   'device_type'     => {
47     label             => 'Device type',
48     type              => 'select',
49     options           => [ 1, 2 ],
50     option_labels     => { 1 => 'IP PBX, e.g. Asterisk',
51                            2 => 'IP phone or softphone',
52                          },
53   },
54   'canada_routing'    => {
55     label             => 'Canada routing policy',
56     type              => 'select',
57     options           => [ 1, 2 ],
58     option_labels     => { 1 => 'Value (lowest price)',
59                            2 => 'Premium (highest quality)'
60                          },
61   },
62   'international_route' => { # yes, 'route'
63     label             => 'International routing policy',
64     type              => 'select',
65     options           => [ 0, 1, 2 ],
66     option_labels     => { 0 => 'Disable international calls',
67                            1 => 'Value (lowest price)',
68                            2 => 'Premium (highest quality)'
69                          },
70   },
71   'cnam_lookup' => {
72     label             => 'Enable CNAM lookup on incoming calls',
73     type              => 'checkbox',
74   },
75
76 ;
77
78 tie my %roles, 'Tie::IxHash',
79   'subacct'       => {  label     => 'SIP client',
80                         svcdb     => 'svc_acct',
81                      },
82   'did'           => {  label     => 'DID',
83                         svcdb     => 'svc_phone',
84                         multiple  => 1,
85                      },
86 ;
87
88 our %info = (
89   'svc'      => [qw( svc_acct svc_phone )],
90   'desc'     =>
91     'Provision subaccounts and DIDs to voip.ms wholesale',
92   'options'  => \%options,
93   'roles'    => \%roles,
94   'no_machine' => 1,
95   'notes'    => <<'END'
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:
98   <OL>
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 
105     the address.</LI>
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>
109   </OL>
110 </P>
111 <P>Export options:
112   <UL>
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>
123   </UL>
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 
126   details.
127 </P>
128 END
129 );
130
131 sub export_insert {
132   my($self, $svc_x) = (shift, shift);
133
134   my $role = $self->svc_role($svc_x);
135   if ( $role eq 'subacct' ) {
136
137     my $error = $self->insert_subacct($svc_x);
138     return "$me $error" if $error;
139
140     my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
141
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
145         if $error;
146     }
147
148   } elsif ( $role eq 'did' ) {
149
150     my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
151     return if !$svc_acct;
152  
153     my $error = $self->insert_did($svc_x, $svc_acct);
154     return "$me $error" if $error;
155
156   }
157   '';
158 }
159
160 sub export_replace {
161   my ($self, $svc_new, $svc_old) = @_;
162   my $role = $self->svc_role($svc_new);
163   my $error;
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);
168   }
169   return "$me $error" if $error;
170   '';
171 }
172
173 sub export_delete {
174   my ($self, $svc_x) = (shift, shift);
175   my $role = $self->svc_role($svc_x);
176   if ( $role eq 'subacct' ) {
177
178     my @existing_dids = ( $self->svc_with_role($svc_x, 'did') );
179
180     my $error;
181     foreach my $svc_phone (@existing_dids) {
182       $error = $self->delete_did($svc_phone);
183       return "$me $error canceling DID ".$svc_phone->phonenum
184         if $error;
185     }
186
187     $error = $self->delete_subacct($svc_x);
188     return "$me $error" if $error;
189
190   } elsif ( $role eq 'did' ) {
191
192     my $svc_acct = $self->svc_with_role($svc_x, 'subacct');
193     return if !$svc_acct;
194  
195     my $error = $self->delete_did($svc_x);
196     return "$me $error" if $error;
197
198   }
199   '';
200 }
201
202 sub export_suspend {
203   my $self = shift;
204   my $svc_x = shift;
205   my $role = $self->svc_role($svc_x);
206   return if $role ne 'subacct'; # can't suspend DIDs directly
207
208   my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
209   return "$me $error" if $error;
210   '';
211 }
212
213 sub export_unsuspend {
214   my $self = shift;
215   my $svc_x = shift;
216   my $role = $self->svc_role($svc_x);
217   return if $role ne 'subacct'; # can't suspend DIDs directly
218
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;
222   '';
223 }
224
225
226 sub insert_subacct {
227   my ($self, $svc_acct) = @_;
228   my $method = 'createSubAccount';
229   my $content = $self->subacct_content($svc_acct);
230
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?
234   }
235
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
238   '';
239 }
240
241 sub insert_did {
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?
248   }
249   '';
250 }
251
252 sub delete_subacct {
253   my ($self, $svc_acct) = @_;
254   my $account = $self->option('account') . '_' . $svc_acct->username;
255
256   my $id = $self->subacct_id($svc_acct);
257   if ( $id =~ /\D/ ) {
258
259     return $id; # it's an error
260
261   } elsif ( $id eq '' ) {
262
263     return ''; # account doesn't exist, don't need to delete
264
265   } # else it's numeric
266
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};
271   }
272   '';
273 }
274
275 sub delete_did {
276   my ($self, $svc_phone) = @_;
277   my $phonenum = $svc_phone->phonenum;
278
279   my $result = $self->api_request('cancelDID', { did => $phonenum });
280   if ( $result->{status} ne 'success' and $result->{status} ne 'invalid_did' )
281   {
282     return $result->{status};
283   }
284   '';
285 }
286
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";
291   }
292   
293   my $id = $self->subacct_id($svc_new);
294   if ( $id =~ /\D/ ) {
295
296     return $id;
297
298   } elsif ( $id eq '' ) {
299
300     # account doesn't exist; provision it anew
301     return $self->insert_subacct($svc_new);
302
303   }
304
305   my $content = $self->subacct_content($svc_new);
306   delete $content->{username};
307   $content->{id} = $id;
308
309   my $result = $self->api_request('setSubAccount', $content);
310   if ( $result->{status} ne 'success' ) {
311     return $result->{status};
312   }
313
314   '';
315 }
316
317 sub replace_did {
318   my ($self, $svc_new, $svc_old) = @_;
319   if ( $svc_new->phonenum ne $svc_old->phonenum ) {
320     return "can't change DID phone number";
321   }
322   # check that there's a subacct set up
323   my $svc_acct = $self->svc_with_role($svc_new, 'subacct')
324     or return '';
325
326   # check for the existing DID
327   my $result = $self->api_request('getDIDsInfo',
328     { did => $svc_new->phonenum }
329   );
330   if ( $result->{status} eq 'invalid_did' ) {
331
332     # provision the DID
333     return $self->insert_did($svc_new, $svc_acct);
334
335   } elsif ( $result->{status} ne 'success' ) {
336
337     return $result->{status};
338
339   }
340
341   my $existing = $result->{dids}[0];
342
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
346   }
347   $result = $self->api_request('setDIDInfo', $content);
348   if ( $result->{status} ne 'success' ) {
349     return $result->{status};
350   }
351
352   return '';
353 }
354
355 #######################
356 # CONVENIENCE METHODS #
357 #######################
358
359 sub subacct_id {
360   my ($self, $svc_acct) = @_;
361   my $account = $self->option('account') . '_' . $svc_acct->username;
362
363   # look up the subaccount's numeric ID
364   my $result = $self->api_request('getSubAccounts', { account => $account });
365   if ( $result->{status} eq 'invalid_account' ) {
366     return '';
367   } elsif ( $result->{status} ne 'success' ) {
368     return "$result->{status} looking up account ID";
369   } else {
370     return $result->{accounts}[0]{id};
371   }
372 }
373
374 sub subacct_content {
375   my ($self, $svc_acct) = @_;
376
377   my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
378
379   my $desc = $svc_acct->finger || $svc_acct->username;
380   my $intl = $self->option('international_route');
381   my $lockintl = 0;
382   if ($intl == 0) {
383     $intl = 1; # can't send zero
384     $lockintl = 1;
385   }
386
387   my %auth;
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)) ),
394             );
395   } else {
396     %auth = ( auth_type => $self->option('auth_type'),
397               password  => $svc_acct->_password,
398               ip        => $svc_acct->slipip,
399             );
400   }
401   return {
402     username            => $svc_acct->username,
403     description         => $desc,
404     %auth,
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',
412     dtmf_mode           => 'AUTO',
413     nat                 => 'yes',
414   };
415 }
416
417 sub did_content {
418   my ($self, $svc_phone, $svc_acct) = @_;
419
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";
427   return {
428     did                 => $phonenum,
429     routing             => "account:$account",
430     # secondary routing options (failovers, voicemail) are outside our 
431     # scope here
432     # though we could support them using the "forwarddst" field?
433     pop                 => $popnum,
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'),
438   };
439 }
440
441 #################
442 # DID SELECTION #
443 #################
444
445 sub get_dids_npa_select { 0 } # all Canadian VoIP providers seem to have this
446
447 sub get_dids {
448   my $self = shift;
449   my %opt = @_;
450
451   my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
452
453   if ( $opt{'region'} ) {
454
455     # return numbers (probably shouldn't cache this)
456     my ($ratecenter, $province) = $opt{'region'} =~ /^(.*), (..)$/;
457     my $country = $self->cache('province_country')->{ $province };
458     my $result;
459     if ( $country eq 'CAN' ) {
460       $result = $self->api_insist('getDIDsCAN',
461                                   { province => $province,
462                                     ratecenter => $ratecenter
463                                   }
464                                  );
465     } elsif ( $country eq 'USA' ) {
466       $result = $self->api_insist('getDIDsUSA',
467                                   { state => $province,
468                                     ratecenter => $ratecenter
469                                   }
470                                  );
471     }
472     my @return = map { $_->{did} } @{ $result->{dids} };
473     return \@return;
474   } else {
475
476     if ( $opt{'state'} ) {
477       my $province = $opt{'state'};
478
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
481       # doesn't exist.
482       return $self->cache('province_city', $province) || [];
483
484     } else {
485
486       # return a list of provinces
487       return [
488         @{ $self->cache('country_province')->{CAN} },
489         @{ $self->cache('country_province')->{USA} },
490       ];
491     }
492   }
493 }
494
495 sub get_sip_servers {
496   my $self = shift;
497   return [ sort keys %{ $self->cache('server_popnum') } ];
498 }
499
500 sub cache {
501   my $self = shift;
502   my $element = shift or return;
503   my $province = shift;
504
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,
509   });
510
511   if ( $element eq 'province_city' ) {
512     $element .= ".$province";
513   }
514   return $CACHE->get($element) || $self->reload_cache($element);
515 }
516
517 sub reload_cache {
518   my $self = shift;
519   my $element = shift;
520   if ( $element eq 'province_country' or $element eq 'country_province' ) {
521     # populate provinces/states
522
523     my %province_country;
524     my %country_province = ( CAN => [], USA => [] );
525
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;
530     }
531
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;
536     }
537
538     $CACHE->set('province_country', \%province_country);
539     $CACHE->set('country_province', \%country_province);
540     return $CACHE->get($element);
541
542   } elsif ( $element eq 'server_popnum' ) {
543
544     my $result = $self->api_insist('getServersInfo');
545     my %server_popnum;
546     foreach (@{ $result->{servers} }) {
547       $server_popnum{ $_->{server_hostname} } = $_->{server_pop};
548     }
549
550     $CACHE->set('server_popnum', \%server_popnum);
551     return \%server_popnum;
552
553   } elsif ( $element =~ /^province_city\.(\w+)$/ ) {
554
555     my $province = $1;
556
557     # then get the ratecenters for that province
558     my $country = $self->cache('province_country')->{$province};
559     my @ratecenters;
560
561     if ( $country eq 'CAN' ) {
562
563       my $result = $self->api_insist('getRateCentersCAN',
564                                    { province => $province });
565
566       foreach (@{ $result->{ratecenters} }) {
567         my $ratecenter = $_->{ratecenter} . ", $province"; # disambiguate
568         push @ratecenters, $ratecenter;
569       }
570
571     } elsif ( $country eq 'USA' ) {
572
573       my $result = $self->api_insist('getRateCentersUSA',
574                                    { state => $province });
575       foreach (@{ $result->{ratecenters} }) {
576         my $ratecenter = $_->{ratecenter} . ", $province";
577         push @ratecenters, $ratecenter;
578       }
579
580     }
581
582     $CACHE->set($element, \@ratecenters);
583     return \@ratecenters;
584
585   } else {
586     return;
587   }
588 }
589
590 ##############
591 # API ACCESS #
592 ##############
593
594 =item api_request METHOD, CONTENT
595
596 Makes a REST request with method name METHOD, and POST content CONTENT (as
597 a hashref).
598
599 =cut
600
601 sub api_request {
602   my $self = shift;
603   my ($method, $content) = @_;
604   $DEBUG ||= 1 if $self->option('debug');
605   my $url = URI->new($base_url);
606   $url->query_form(
607     'method'        => $method,
608     'api_username'  => $self->option('username'),
609     'api_password'  => $self->option('password'),
610     %$content
611   );
612
613   my $request = GET($url,
614     'Accept'        => 'text/json',
615   );
616
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 };
623   }
624
625   return decode_json($response->content);
626 }
627
628 =item api_insist METHOD, CONTENT
629
630 Exactly like L</api_request>, but if the returned "status" is not "success",
631 throws an exception.
632
633 =cut
634
635 sub api_insist {
636   my $self = shift;
637   my $method = $_[0];
638   my $result = $self->api_request(@_);
639   if ( $result->{status} eq 'success' ) {
640     return $result;
641   } elsif ( $result->{status} ) {
642     die "$me $method: $result->{status}\n";
643   } else {
644     die "$me $method: no status returned\n";
645   }
646 }
647
648 1;