RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / part_export / thinktel.pm
1 package FS::part_export::thinktel;
2
3 use base qw( FS::part_export );
4 use strict;
5
6 use Tie::IxHash;
7 use URI::Escape;
8 use LWP::UserAgent;
9 use URI::Escape;
10 use Cpanel::JSON::XS;
11
12 use FS::Record qw( qsearch qsearchs );
13
14 our $me = '[Thinktel VoIP]';
15 our $DEBUG = 1;
16 our $base_url = 'https://api.thinktel.ca/rest.svc/';
17
18 # cache cities and provinces
19 our %CACHE;
20 our $cache_timeout = 60; # seconds
21 our $last_cache_update = 0;
22
23 # static data
24
25 tie my %locales, 'Tie::IxHash', (
26   EnglishUS => 0,
27   EnglishUK => 1,
28   EnglishCA => 2,
29   UserDefined1 => 3,
30   UserDefined2 => 4,
31   FrenchCA  => 5,
32   SpanishLatinAmerica => 6
33 );
34
35 tie my %options, 'Tie::IxHash',
36   'username'        => { label => 'Thinktel username', },
37   'password'        => { label => 'Thinktel password', },
38   'debug'           => { label => 'Enable debugging', type => 'checkbox', value => 1 },
39   'plan_id'         => { label => 'Trunk plan ID' },
40   'locale'          => {
41     label => 'Locale',
42     type => 'select',
43     options => [ keys %locales ],
44   },
45   'proxy'           => {
46     label => 'SIP Proxy',
47     type => 'select',
48     options =>
49       [ 'edm.trk.tprm.ca', 'tor.trk.tprm.ca' ],
50   },
51   'trunktype'       => {
52     label => 'SIP Trunk Type',
53     type => 'select',
54     options => [
55       'Avaya CM/SM',
56       'Default SIP MG Model',
57       'Microsoft Lync Server 2010',
58     ],
59   },
60
61 ;
62
63 tie my %roles, 'Tie::IxHash',
64   'trunk'   => {  label     => 'SIP trunk',
65                   svcdb     => 'svc_phone',
66                },
67   'did'     => {  label     => 'DID',
68                   svcdb     => 'svc_phone',
69                   multiple  => 1,
70                },
71   'gateway' => {  label     => 'SIP gateway',
72                   svcdb     => 'svc_pbx',
73                   multiple  => 1,
74                },
75 ;
76
77 our %info = (
78   'svc'         => [qw( svc_phone svc_pbx)],
79   'desc'        =>
80     'Provision trunks and DIDs to Thinktel VoIP',
81   'options'     => \%options,
82   'roles'       => \%roles,
83   'no_machine'  => 1,
84   'notes'       => <<'END'
85 <P>Export to Thinktel SIP Trunking service.</P>
86 <P>This requires three service definitions to be configured:
87   <OL>
88     <LI>A phone service for the SIP trunk. This should be attached to the 
89     export in the "trunk" role. Usually there will be only one of these
90     per package. The <I>max_simultaneous</i> field of this service will set 
91     the channel limit on the trunk. The <i>sip_password</i> will be used for
92     all gateways.</LI>
93     <LI>A phone service for a DID. This should be attached in the "did" role.
94     DIDs should have no properties other than the number and the E911 
95     location.</LI>
96     <LI>A PBX service for the customer's SIP gateway (Asterisk, OpenPBX, etc. 
97     device). This should be attached in the "gateway" role. The <i>ip_addr</i> 
98     field should be set to the static IP address that will receive calls. 
99     There may be more than one of these on the trunk.</LI>
100   </OL>
101   All three services must be within the same package. The "pbxsvc" field of
102   phone services will be ignored, as the DIDs do not belong to a specific 
103   svc_pbx in a multi-gateway setup.
104 </P>
105 END
106 );
107
108 sub check_svc { # check the service for validity
109   my($self, $svc_x) = (shift, shift);
110   my $role = $self->svc_role($svc_x)
111     or return "No export role is assigned to this service type.";
112   if ( $role eq 'trunk' ) {
113     if (! $svc_x->isa('FS::svc_phone')) {
114       return "This is the wrong type of service (should be svc_phone).";
115     }
116     if (length($svc_x->sip_password) == 0
117         or length($svc_x->sip_password) > 14) {
118       return "SIP password must be 1 to 14 characters.";
119     }
120   } elsif ( $role eq 'did' ) {
121     # nothing really to check
122   } elsif ( $role eq 'gateway' ) {
123     if ($svc_x->max_simultaneous == 0) {
124       return "The maximum simultaneous calls field must be > 0."
125     }
126     if (!$svc_x->ip_addr) {
127       return "The gateway must have an IP address."
128     }
129   }
130
131   '';
132 }
133
134 sub _export_insert {
135   my($self, $svc_x) = (shift, shift);
136
137   my $error = $self->check_svc($svc_x);
138   return $error if $error;
139   my $role = $self->svc_role($svc_x);
140   $self->queue_action("insert_$role", $svc_x->svcnum);
141 }
142
143 sub queue_action {
144   my $self = shift;
145   my $action = shift; #'action_role' format: 'insert_did', 'delete_trunk', etc.
146   my $svcnum = shift;
147   my @arg = ($self->exportnum, $svcnum, @_);
148
149   my $job = FS::queue->new({
150       job => 'FS::part_export::thinktel::'.$action,
151       svcnum => $svcnum,
152   });
153
154   $job->insert(@arg);
155 }
156
157 sub insert_did {
158   my ($exportnum, $svcnum) = @_;
159   my $self = FS::part_export->by_key($exportnum);
160   my $svc_x = FS::svc_phone->by_key($svcnum);
161
162   my $phonenum = $svc_x->phonenum;
163   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
164     or return; # non-fatal; just wait for the trunk to be created
165
166   my $trunknum = $trunk_svc->phonenum;
167
168   my $endpoint = "SipTrunks/$trunknum/Dids";
169   my $content = [ { Number  => $phonenum } ];
170
171   my $result = $self->api_request('POST', $endpoint, $content);
172
173   # probably can only be one of these
174   my $error = join("\n",
175     map { $_->{Message} } grep { $_->{Reply} != 1 } @$result
176   );
177
178   if ( $error ) {
179     warn "$me error provisioning $phonenum to $trunknum: $error\n";
180     die "$me $error";
181   }
182
183   # now insert the V911 record
184   $endpoint = "V911s";
185   $content = $self->e911_content($svc_x);
186
187   $result = $self->api_request('POST', $endpoint, $content);
188   if ( $result->{Reply} != 1 ) {
189     $error = "$me $result->{Message}";
190     # then delete the DID to keep things consistent
191     warn "$me error configuring e911 for $phonenum: $error\nReverting DID order.\n";
192     $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
193     $result = $self->api_request('DELETE', $endpoint);
194     if ( $result->{Reply} != 1 ) {
195       warn "Failed: $result->{Message}\n";
196       die "$error. E911 provisioning failed, but the DID could not be deleted: '" . $result->{Message} . "'. You may need to remove the DID manually.";
197     }
198     die $error;
199   }
200 }
201
202 sub insert_gateway {
203   my ($exportnum, $svcnum) = @_;
204   my $self = FS::part_export->by_key($exportnum);
205   my $svc_x = FS::svc_pbx->by_key($svcnum);
206
207   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
208     or return;
209
210   my $trunknum = $trunk_svc->phonenum;
211   # and $svc_x is a svc_pbx service
212
213   my $endpoint = "SipBindings";
214   my $content = {
215     ContactIPAddress  => $svc_x->ip_addr,
216     ContactPort       => 5060,
217     IPMatchRequired   => Cpanel::JSON::XS::true,
218     SipDomainName     => $self->option('proxy'),
219     SipTrunkType      => $self->option('trunktype'),
220     SipUsername       => $trunknum,
221     SipPassword       => $trunk_svc->sip_password,
222   };
223   my $result = $self->api_request('POST', $endpoint, $content);
224
225   if ( $result->{Reply} != 1 ) {
226     die "$me ".$result->{Message};
227   }
228
229   # store the binding ID in the service
230   my $binding_id = $result->{ID};
231   warn "$me created SIP binding with ID $binding_id\n" if $DEBUG;
232   local $FS::svc_Common::noexport_hack = 1;
233   $svc_x->set('uuid', $binding_id);
234   my $error = $svc_x->replace;
235   if ( $error ) {
236     $error = "$me storing the SIP binding ID in the database: $error";
237   } else {
238     # link the main trunk record to the IP address binding
239     $endpoint = "SipTrunks/$trunknum/Lines";
240     $content = {
241       'Channels'     => $svc_x->max_simultaneous,
242       'SipBindingID' => $binding_id,
243       'TrunkNumber'  => $trunknum,
244     };
245     $result = $self->api_request('POST', $endpoint, $content);
246     if ( $result->{Reply} != 1 ) {
247       $error = "$me attaching binding $binding_id to $trunknum: " .
248         $result->{Message};
249     }
250   }
251
252   if ( $error ) {
253     # delete the binding
254     $endpoint = "SipBindings/$binding_id";
255     $result = $self->api_request('DELETE', $endpoint);
256     if ( $result->{Reply} != 1 ) {
257       my $addl_error = $result->{Message};
258       warn "$error. The SIP binding could not be deleted: '$addl_error'.\n";
259     }
260     die $error;
261   }
262 }
263
264 sub insert_trunk {
265   my ($exportnum, $svcnum) = @_;
266   my $self = FS::part_export->by_key($exportnum);
267   my $svc_x = FS::svc_phone->by_key($svcnum);
268   my $phonenum = $svc_x->phonenum;
269
270   my $endpoint = "SipTrunks";
271   my $content = {
272     Account           => $self->option('username'),
273     Enabled           => Cpanel::JSON::XS::true,
274     Label             => $svc_x->phone_name_or_cust,
275     Locale            => $locales{$self->option('locale')},
276     MaxChannels       => $svc_x->max_simultaneous,
277     Number            => { Number => $phonenum },
278     PlanID            => $self->option('plan_id'),
279     ThirdPartyLabel   => $svc_x->svcnum,
280   };
281
282   my $result = $self->api_request('POST', $endpoint, $content);
283   if ( $result->{Reply} != 1 ) {
284     die "$me ".$result->{Message};
285   }
286
287   my @gateways = $self->svc_with_role($svc_x, 'gateway');
288   my @dids = $self->svc_with_role($svc_x, 'did');
289   warn "$me inserting dependent services to trunk #$phonenum\n".
290        "gateways: ".@gateways."\nDIDs: ".@dids."\n";
291
292   foreach my $svc_x (@gateways, @dids) {
293     $self->export_insert($svc_x); # will generate additional queue jobs
294   }
295 }
296
297 sub _export_replace {
298   my ($self, $svc_new, $svc_old) = @_;
299
300   my $error = $self->check_svc($svc_new);
301   return $error if $error;
302
303   my $role = $self->svc_role($svc_new)
304     or return "No export role is assigned to this service type.";
305
306   if ( $role eq 'did' and $svc_new->phonenum ne $svc_old->phonenum ) {
307     my $pkgnum = $svc_new->cust_svc->pkgnum;
308     # not that the UI allows this...
309     return $self->queue_action("delete_did", $svc_old->svcnum, 
310                                $svc_old->phonenum, $pkgnum)
311         || $self->queue_action("insert_did", $svc_new->svcnum);
312   }
313
314   my %args;
315   if ( $role eq 'trunk' and $svc_new->sip_password ne $svc_old->sip_password ) {
316     # then trigger a password change
317     %args = (password_change => 1);
318   }
319     
320   $self->queue_action("replace_$role", $svc_new->svcnum, %args);
321 }
322
323 sub replace_trunk {
324   my ($exportnum, $svcnum, %args) = @_;
325   my $self = FS::part_export->by_key($exportnum);
326   my $svc_x = FS::svc_phone->by_key($svcnum);
327
328   my $enabled = Cpanel::JSON::XS::is_bool( $self->cust_svc->cust_pkg->susp == 0 );
329
330   my $phonenum = $svc_x->phonenum;
331   my $endpoint = "SipTrunks/$phonenum";
332   my $content = {
333     Account           => $self->options('username'),
334     Enabled           => $enabled,
335     Label             => $svc_x->phone_name_or_cust,
336     Locale            => $self->option('locale'),
337     MaxChannels       => $svc_x->max_simultaneous,
338     Number            => $phonenum,
339     PlanID            => $self->option('plan_id'),
340     ThirdPartyLabel   => $svc_x->svcnum,
341   };
342
343   my $result = $self->api_request('PUT', $endpoint, $content);
344   if ( $result->{Reply} != 1 ) {
345     die "$me ".$result->{Message};
346   }
347
348   if ( $args{password_change} ) {
349     # then propagate the change to the bindings
350     my @bindings = $self->svc_with_role($svc_x->gateway);
351     foreach my $svc_pbx (@bindings) {
352       my $error = $self->export_replace($svc_pbx);
353       die "$me updating password on bindings: $error\n" if $error;
354     }
355   }
356 }
357
358 sub replace_did {
359   # we don't handle phonenum/trunk changes
360   my ($exportnum, $svcnum, %args) = @_;
361   my $self = FS::part_export->by_key($exportnum);
362   my $svc_x = FS::svc_phone->by_key($svcnum);
363
364   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
365     or return;
366   my $phonenum = $svc_x->phonenum;
367   my $endpoint = "V911s/$phonenum";
368   my $content = $self->e911_content($svc_x);
369
370   my $result = $self->api_request('PUT', $endpoint, $content);
371   if ( $result->{Reply} != 1 ) {
372     die "$me ".$result->{Message};
373   }
374 }
375
376 sub replace_gateway {
377   my ($exportnum, $svcnum, %args) = @_;
378   my $self = FS::part_export->by_key($exportnum);
379   my $svc_x = FS::svc_pbx->by_key($svcnum);
380
381   my $trunk_svc = $self->svc_with_role($svc_x, 'trunk')
382     or return;
383
384   my $binding_id = $svc_x->uuid;
385
386   my $trunknum = $trunk_svc->phonenum;
387
388   my $endpoint = "SipBindings/$binding_id";
389   # get the canonical name of the binding
390   my $result = $self->api_request('GET', $endpoint);
391   if ( $result->{Message} ) {
392     # then assume the binding is not yet set up
393     return $self->export_insert($svc_x);
394   }
395   my $binding_name = $result->{Name};
396  
397   my $content = {
398     ContactIPAddress  => $svc_x->ip_addr,
399     ContactPort       => 5060,
400     ID                => $binding_id,
401     IPMatchRequired   => Cpanel::JSON::XS::true,
402     Name              => $binding_name,
403     SipDomainName     => $self->option('proxy'),
404     SipTrunkType      => $self->option('trunktype'),
405     SipUsername       => $trunknum,
406     SipPassword       => $trunk_svc->sip_password,
407   };
408   $result = $self->api_request('PUT', $endpoint, $content);
409
410   if ( $result->{Reply} != 1 ) {
411     die "$me ".$result->{Message};
412   }
413 }
414
415 sub _export_delete {
416   my ($self, $svc_x) = (shift, shift);
417
418   my $role = $self->svc_role($svc_x)
419     or return; # not really an error
420   my $pkgnum = $svc_x->cust_svc->pkgnum;
421
422   # delete_foo(svcnum, identifier, pkgnum)
423   # so that we can find the linked services later
424
425   if ( $role eq 'trunk' ) {
426     $self->queue_action("delete_trunk", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
427   } elsif ( $role eq 'did' ) {
428     $self->queue_action("delete_did", $svc_x->svcnum, $svc_x->phonenum, $pkgnum);
429   } elsif ( $role eq 'gateway' ) {
430     $self->queue_action("delete_gateway", $svc_x->svcnum, $svc_x->uuid, $pkgnum);
431   }
432 }
433
434 sub delete_trunk {
435   my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
436   my $self = FS::part_export->by_key($exportnum);
437
438   my $endpoint = "SipTrunks/$phonenum";
439
440   my $result = $self->api_request('DELETE', $endpoint);
441   if ( $result->{Reply} != 1 ) {
442     die "$me ".$result->{Message};
443   }
444
445   # deleting this on the server side should remove all DIDs, but we still
446   # need to remove IP bindings
447   my @gateways = $self->svc_with_role($pkgnum, 'gateway');
448   foreach (@gateways) {
449     $_->export_delete;
450   }
451 }
452
453 sub delete_did {
454   my ($exportnum, $svcnum, $phonenum, $pkgnum) = @_;
455   my $self = FS::part_export->by_key($exportnum);
456
457   my $endpoint = "V911s/$phonenum";
458
459   my $result = $self->api_request('DELETE', $endpoint);
460   if ( $result->{Reply} != 1 ) {
461     warn "$me ".$result->{Message}; # but continue removing the DID
462   }
463
464   my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk')
465     or return ''; # then it's already been removed, most likely
466
467   my $trunknum = $trunk_svc->phonenum;
468   $endpoint = "SipTrunks/$trunknum/Dids/$phonenum";
469
470   $result = $self->api_request('DELETE', $endpoint);
471   if ( $result->{Reply} != 1 ) {
472     die "$me ".$result->{Message};
473   }
474 }
475
476 sub delete_gateway {
477   my ($exportnum, $svcnum, $binding_id, $pkgnum) = @_;
478   my $self = FS::part_export->by_key($exportnum);
479
480   my $trunk_svc = $self->svc_with_role($pkgnum, 'trunk');
481   if ( $trunk_svc ) {
482     # detach the address from the trunk
483     my $trunknum = $trunk_svc->phonenum;
484     my $endpoint = "SipTrunks/$trunknum/Lines/$binding_id";
485     my $result = $self->api_request('DELETE', $endpoint);
486     if ( $result->{Reply} != 1 ) {
487       die "$me ".$result->{Message};
488     }
489   }
490
491   # seems not to be necessary?
492   #my $endpoint = "SipBindings/$binding_id";
493   #my $result = $self->api_request('DELETE', $endpoint);
494   #if ( $result->{Reply} != 1 ) {
495   #  die "$me ".$result->{Message};
496   #}
497 }
498
499 sub e911_content {
500   my ($self, $svc_x) = @_;
501
502   my %location = $svc_x->location_hash;
503   my $cust_main = $svc_x->cust_main;
504
505   my $content = {
506     City            => $location{'city'},
507     FirstName       => $cust_main->first,
508     LastName        => $cust_main->last,
509     Number          => $svc_x->phonenum,
510     OtherInfo       => ($svc_x->phone_name || ''),
511     PostalZip       => $location{'zip'},
512     ProvinceState   => $location{'state'},
513     SuiteNumber     => $location{'address2'},
514   };
515   if ($location{address1} =~ /^(\w+) +(.*)$/) {
516     $content->{StreetNumber} = $1;
517     $content->{StreetName} = $2;
518   } else {
519     $content->{StreetNumber} = '';
520     $content->{StreetName} = $location{address1};
521   }
522
523   return $content;
524 }
525
526 # select by province + ratecenter, not by NPA
527 sub get_dids_npa_select { 0 }
528
529 sub get_dids {
530   my $self = shift;
531   local $DEBUG = 0;
532
533   my %opt = @_;
534
535   my ($exportnum) = $self->exportnum =~ /^(\d+)$/;
536
537   if ( $opt{'region'} ) {
538
539     # return numbers (probably shouldn't cache this)
540     my $state = $self->ratecenter_cache->{city}{ $opt{'region'} };
541     my $ratecenter = $opt{'region'} . ', ' . $state;
542     my $endpoint = uri_escape("RateCenters/$ratecenter/Next10");
543     my $result = $self->api_request('GET', $endpoint);
544     if (ref($result) eq 'HASH') {
545       die "$me error fetching available DIDs in '$ratecenter': ".$result->{Message}."\n";
546     }
547     my @return;
548     foreach my $row (@$result) {
549       push @return, $row->{Number};
550     }
551     return \@return;
552
553   } else {
554
555     if ( $opt{'state'} ) {
556
557       # ratecenter_cache will refresh the cache if necessary, and die on 
558       # failure. default here is only in case someone gives us a state that
559       # doesn't exist.
560       return $self->ratecenter_cache->{province}->{ $opt{'state'} } || [];
561
562     } else {
563
564       return $self->ratecenter_cache->{all_provinces};
565
566     }
567   }
568 }
569
570 sub ratecenter_cache {
571   # in-memory caching is probably sufficient...Thinktel's API is pretty fast
572   my $self = shift;
573
574   if (keys(%CACHE) == 0 or ($last_cache_update + $cache_timeout < time) ) {
575     %CACHE = ( province => {}, city => {} );
576     my $result = $self->api_request('GET', 'RateCenters');
577     if (ref($result) eq 'HASH') {
578       die "$me error fetching ratecenters: ".$result->{Message}."\n";
579     }
580     foreach my $row (@$result) {
581       my ($city, $province) = split(', ', $row->{Name});
582       $CACHE{province}->{$province} ||= [];
583       push @{ $CACHE{province}->{$province} }, $city;
584       $CACHE{city}{$city} = $province;
585     }
586     $CACHE{all_provinces} = [ sort keys %{ $CACHE{province} } ];
587     $last_cache_update = time;
588   }
589   
590   return \%CACHE;
591 }
592
593 =item queue_api_request METHOD, ENDPOINT, CONTENT, JOB
594
595 Adds a queue job to make a REST request.
596
597 =item api_request METHOD, ENDPOINT[, CONTENT ]
598
599 Makes a REST request using METHOD, to URL ENDPOINT (relative to the API
600 base). For POST or PUT requests, CONTENT is the content to submit, as a
601 hashref. Returns the decoded response; generally, on failure, this will
602 have a 'Message' element.
603
604 =cut
605
606 sub api_request {
607   my $self = shift;
608   my ($method, $endpoint, $content) = @_;
609   my $json = Cpanel::JSON::XS->new->canonical(1); # hash keys are ordered
610
611   $DEBUG ||= 1 if $self->option('debug');
612
613   my $url = $base_url . $endpoint;
614   if ( ref($content) ) {
615     $content = $json->encode($content);
616   }
617
618   # PUT() == _simple_req('PUT'), etc.
619   my $request = HTTP::Request::Common::_simple_req(
620     $method,
621     $url,
622     'Accept'        => 'text/json',
623     'Content-Type'  => 'text/json',
624     'Content'       => $content,
625   );
626
627   $request->authorization_basic(
628     $self->option('username'), $self->option('password')
629   );
630
631   my $stringify = 'content';
632   $stringify = 'as_string' if $DEBUG > 1; # includes HTTP headers
633   warn "$me $method $endpoint\n" . $request->$stringify ."\n" if $DEBUG;
634   my $ua = LWP::UserAgent->new;
635   my $response = $ua->request($request);
636   warn "$me received:\n" . $response->$stringify ."\n" if $DEBUG;
637   if ( ! $response->is_success ) {
638     # fake up a response
639     return { Message => $response->content };
640   }
641
642   return $json->decode($response->content);
643 }
644
645 1;