sipwise export, part 2
[freeside.git] / FS / FS / part_export / sipwise.pm
1 package FS::part_export::sipwise;
2
3 use base qw( FS::part_export );
4 use strict;
5
6 use FS::Record qw(qsearch qsearchs dbh);
7 use Tie::IxHash;
8 use LWP::UserAgent;
9 use URI;
10 use Cpanel::JSON::XS;
11 use HTTP::Request::Common qw(GET POST PUT DELETE);
12 use FS::Misc::DateTime qw(parse_datetime);
13 use DateTime;
14 use Number::Phone;
15 use Try::Tiny;
16
17 our $me = '[sipwise]';
18 our $DEBUG = 2;
19
20 tie my %options, 'Tie::IxHash',
21   'port'            => { label => 'Port' },
22   'username'        => { label => 'API username', },
23   'password'        => { label => 'API password', },
24   'debug'           => { label => 'Enable debugging', type => 'checkbox', value => 1 },
25   'billing_profile' => {
26     label             => 'Billing profile',
27     default           => 'default', # that's what it's called
28   },
29   'reseller_id'     => { label => 'Reseller ID' },
30   'ssl_no_verify'   => { label => 'Skip SSL certificate validation',
31                          type  => 'checkbox',
32                        },
33 ;
34
35 tie my %roles, 'Tie::IxHash',
36   'subscriber'    => {  label     => 'Subscriber',
37                         svcdb     => 'svc_acct',
38                         multiple  => 1,
39                      },
40   'did'           => {  label     => 'DID',
41                         svcdb     => 'svc_phone',
42                         multiple  => 1,
43                      },
44 ;
45
46 our %info = (
47   'svc'      => [qw( svc_acct svc_phone )],
48   'desc'     => 'Provision to a Sipwise sip:provider server',
49   'options'  => \%options,
50   'roles'    => \%roles,
51   'notes'    => <<'END'
52 <P>Export to a <b>sip:provider</b> server.</P>
53 <P>This requires two service definitions to be configured on the same package:
54   <OL>
55     <LI>An account service for a SIP client account ("subscriber"). The
56     <i>username</i> will be the SIP username. The <i>domsvc</i> should point
57     to a domain service to use as the SIP domain name.</LI>
58     <LI>A phone service for a DID. The <i>phonenum</i> here will be a PSTN
59     number. The <i>forward_svcnum</i> field should be set to the account that
60     will receive calls at this number.
61   </OL>
62 </P>
63 <P>Export options:
64 </P>
65 END
66 );
67
68 sub export_insert {
69   my($self, $svc_x) = (shift, shift);
70
71   my $error;
72   my $role = $self->svc_role($svc_x);
73   if ( $role eq 'subscriber' ) {
74
75     try { $self->insert_subscriber($svc_x) }
76     catch { $error = $_ };
77
78   } elsif ( $role eq 'did' ) {
79
80     try { $self->export_did($svc_x) }
81     catch { $error = $_ };
82
83   }
84   return "$me $error" if $error;
85   '';
86 }
87
88 sub export_replace {
89   my ($self, $svc_new, $svc_old) = @_;
90   my $role = $self->svc_role($svc_new);
91
92   my $error;
93   if ( $role eq 'subscriber' ) {
94
95     try { $self->replace_subscriber($svc_new, $svc_old) }
96     catch { $error = $_ };
97
98   } elsif ( $role eq 'did' ) {
99
100     try { $self->export_did($svc_new, $svc_old) }
101     catch { $error = $_ };
102
103   }
104   return "$me $error" if $error;
105   '';
106 }
107
108 sub export_delete {
109   my ($self, $svc_x) = (shift, shift);
110   my $role = $self->svc_role($svc_x);
111   my $error;
112
113   if ( $role eq 'subscriber' ) {
114
115     # no need to remove DIDs from it, just drop the subscriber record
116     try { $self->delete_subscriber($svc_x) }
117     catch { $error = $_ };
118
119   } elsif ( $role eq 'did' ) {
120
121     try { $self->export_did($svc_x) }
122     catch { $error = $_ };
123
124   }
125   return "$me $error" if $error;
126   '';
127 }
128
129 # XXX NOT DONE YET
130 sub export_suspend {
131   my $self = shift;
132   my $svc_x = shift;
133   my $role = $self->svc_role($svc_x);
134   return if $role ne 'subacct'; # can't suspend DIDs directly
135
136   my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
137   return "$me $error" if $error;
138   '';
139 }
140
141 sub export_unsuspend {
142   my $self = shift;
143   my $svc_x = shift;
144   my $role = $self->svc_role($svc_x);
145   return if $role ne 'subacct'; # can't suspend DIDs directly
146
147   $svc_x->set('unsuspended', 1); # hack to tell replace_subacct to do it
148   my $error = $self->replace_subacct($svc_x, $svc_x); #same
149   return "$me $error" if $error;
150   '';
151 }
152
153 #############
154 # CUSTOMERS #
155 #############
156
157 =item get_customer SERVICE
158
159 Returns the Sipwise customer record that should belong to SERVICE. This is
160 based on the pkgnum field.
161
162 =cut
163
164 sub get_customer {
165   my $self = shift;
166   my $svc = shift;
167   my $pkgnum = $svc->cust_svc->pkgnum;
168   my $custid = "cust_pkg#$pkgnum";
169
170   my @cust = $self->api_query('customers', [ external_id => $custid ]);
171   warn "$me multiple customers for external_id $custid.\n" if scalar(@cust) > 1;
172   $cust[0];
173 }
174
175 sub find_or_create_customer {
176   my $self = shift;
177   my $svc = shift;
178   my $cust = $self->get_customer($svc);
179   return $cust if $cust;
180
181   my $cust_pkg = $svc->cust_svc->cust_pkg;
182   my $cust_main = $cust_pkg->cust_main;
183   my $cust_location = $cust_pkg->cust_location;
184   my ($email) = $cust_main->invoicing_list_emailonly;
185   my $custid = 'cust_pkg#' . $cust_pkg->pkgnum;
186
187   # find the billing profile
188   my ($billing_profile) = $self->api_query('billingprofiles',
189     [
190       'handle'        => $self->option('billing_profile'),
191       'reseller_id'   => $self->option('reseller_id'),
192     ]
193   );
194   if (!$billing_profile) {
195     die "can't find billing profile '". $self->option('billing_profile') . "'\n";
196   }
197   my $bpid = $billing_profile->{id};
198
199   # contacts unfortunately have no searchable external_id or other field
200   # like that, so we can't go location -> package -> service
201   my $contact = $self->api_create('customercontacts',
202     {
203       'city'          => $cust_location->city,
204       'company'       => $cust_main->company,
205       'country'       => $cust_location->country,
206       'email'         => $email,
207       'faxnumber'     => $cust_main->fax,
208       'firstname'     => $cust_main->first,
209       'lastname'      => $cust_main->last,
210       'mobilenumber'  => $cust_main->mobile,
211       'phonenumber'   => ($cust_main->daytime || $cust_main->night),
212       'postcode'      => $cust_location->zip,
213       'reseller_id'   => $self->option('reseller_id'),
214       'street'        => $cust_location->address1,
215     }
216   );
217
218   $cust = $self->api_create('customers',
219     {
220       'status'      => 'active',
221       'type'        => 'sipaccount',
222       'contact_id'  => $contact->{id},
223       'external_id' => $custid,
224       'billing_profile_id' => $bpid,
225     }
226   );
227
228   $cust;
229 }
230
231 ###########
232 # DOMAINS #
233 ###########
234
235 =item find_or_create_domain DOMAIN
236
237 Returns the record for the domain object named DOMAIN. If necessary, will
238 create it first.
239
240 =cut
241
242 sub find_or_create_domain {
243   my $self = shift;
244   my $domainname = shift;
245   my ($domain) = $self->api_query('domains', [ 'domain' => $domainname ]);
246   return $domain if $domain;
247
248   $self->api_create('domains',
249     {
250       'domain'        => $domainname,
251       'reseller_id'   => $self->option('reseller_id'),
252     }
253   );
254 }
255
256 ########
257 # DIDS #
258 ########
259
260 =item acct_for_did SVC_PHONE
261
262 Returns the subscriber svc_acct linked to SVC_PHONE.
263
264 =cut
265
266 sub acct_for_did {
267   my $self = shift;
268   my $svc_phone = shift;
269   my $svcnum = $svc_phone->forward_svcnum or return;
270   my $svc_acct = FS::svc_acct->by_key($svcnum) or return;
271   $self->svc_role($svc_acct) eq 'subscriber' or return;
272   $svc_acct;
273 }
274
275 =item export_did NEW, OLD
276
277 Refreshes the subscriber information for the service the DID was linked to
278 previously, and the one it's linked to now.
279
280 =cut
281
282 sub export_did {
283   my $self = shift;
284   my ($new, $old) = @_;
285   if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) {
286     $self->replace_subscriber( $self->acct_for_did($old) );
287   }
288   $self->replace_subscriber( $self->acct_for_did($new) );
289 }
290
291 ###############
292 # SUBSCRIBERS #
293 ###############
294
295 =item get_subscriber SVC
296
297 Gets the subscriber record for SVC, if there is one.
298
299 =cut
300
301 sub get_subscriber {
302   my $self = shift;
303   my $svc = shift;
304
305   my $svcnum = $svc->svcnum;
306   my $svcid = "svc#$svcnum";
307
308   my $pkgnum = $svc->cust_svc->pkgnum;
309   my $custid = "cust_pkg#$pkgnum";
310
311   my @subscribers = grep { $_->{external_id} eq $svcid }
312     $self->api_query('subscribers',
313       [ 'customer_external_id' => $custid ]
314     );
315   warn "$me multiple subscribers for external_id $svcid.\n"
316     if scalar(@subscribers) > 1;
317
318   $subscribers[0];
319 }
320
321 # internal method: find DIDs that forward to this service
322
323 sub did_numbers_for_svc {
324   my $self = shift;
325   my $svc = shift;
326   my @numbers;
327   my @dids = qsearch({
328       'table'     => 'svc_phone',
329       'hashref'   => { 'forward_svcnum' => $svc->svcnum }
330   });
331   foreach my $did (@dids) {
332     # only include them if they're interesting to this export
333     if ( $self->svc_role($did) eq 'did' ) {
334       my $phonenum;
335       if ($did->countrycode) {
336         $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum);
337       } else {
338         # the long way
339         my $country = $did->cust_svc->cust_pkg->cust_location->country;
340         $phonenum = Number::Phone->new($country, $did->phonenum);
341       }
342       if (!$phonenum) {
343         die "Can't process phonenum ".$did->countrycode . $did->phonenum . "\n";
344       }
345       push @numbers,
346         { 'cc' => $phonenum->country_code,
347           'ac' => $phonenum->areacode,
348           'sn' => $phonenum->subscriber
349         };
350     }
351   }
352   @numbers;
353 }
354
355 sub insert_subscriber {
356   my $self = shift;
357   my $svc = shift;
358
359   my $cust = $self->find_or_create_customer($svc);
360   my $svcid = "svc#" . $svc->svcnum;
361   my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
362   my $domain = $self->find_or_create_domain($svc->domain);
363
364   my @numbers = $self->did_numbers_for_svc($svc);
365   my $first_number = shift @numbers;
366
367   my $subscriber = $self->api_create('subscribers',
368     {
369       'alias_numbers'   => \@numbers,
370       'customer_id'     => $cust->{id},
371       'display_name'    => $svc->finger,
372       'domain_id'       => $domain->{id},
373       'external_id'     => $svcid,
374       'password'        => $svc->_password,
375       'primary_number'  => $first_number,
376       'status'          => $status,
377       'username'        => $svc->username,
378     }
379   );
380 }
381
382 sub replace_subscriber {
383   my $self = shift;
384   my $svc = shift;
385   my $old = shift || $svc->replace_old;
386   my $svcid = "svc#" . $svc->svcnum;
387
388   my $cust = $self->find_or_create_customer($svc);
389   my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
390   my $domain = $self->find_or_create_domain($svc->domain);
391   
392   my @numbers = $self->did_numbers_for_svc($svc);
393   my $first_number = shift @numbers;
394
395   my $subscriber = $self->get_subscriber($svc);
396
397   if ( $subscriber ) {
398     my $id = $subscriber->{id};
399     if ( $svc->username ne $old->username ) {
400       # have to delete and recreate
401       $self->api_delete("subscribers/$id");
402       $self->insert_subscriber($svc);
403     } else {
404       $self->api_update("subscribers/$id",
405         {
406           'alias_numbers'   => \@numbers,
407           'customer_id'     => $cust->{id},
408           'display_name'    => $svc->finger,
409           'domain_id'       => $domain->{id},
410           'email'           => $svc->email,
411           'external_id'     => $svcid,
412           'password'        => $svc->_password,
413           'primary_number'  => $first_number,
414           'status'          => $status,
415           'username'        => $svc->username,
416         }
417       );
418     }
419   } else {
420     warn "$me subscriber not found for $svcid; creating new\n";
421     $self->insert_subscriber($svc);
422   }
423 }
424
425 sub delete_subscriber {
426   my $self = shift;
427   my $svc = shift;
428   my $svcid = "svc#" . $svc->svcnum;
429   my $pkgnum = $svc->cust_svc->pkgnum;
430   my $custid = "cust_pkg#$pkgnum";
431
432   my $subscriber = $self->get_subscriber($svc);
433
434   if ( $subscriber ) {
435     my $id = $subscriber->{id};
436     $self->api_delete("subscribers/$id");
437   } else {
438     warn "$me subscriber not found for $svcid (would be deleted)\n";
439   }
440
441   my (@other_subs) = $self->api_query('subscribers',
442     [ 'customer_external_id' => $custid ]
443   );
444   if (! @other_subs) {
445     # then it's safe to remove the customer
446     my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]);
447     if (!$cust) {
448       warn "$me customer not found for $custid\n";
449       return;
450     }
451     my $id = $cust->{id};
452     my $contact_id = $cust->{contact_id};
453     if ( $cust->{'status'} ne 'terminated' ) {
454       # can't delete customers, have to cancel them
455       $cust->{'status'} = 'terminated';
456       $cust->{'external_id'} = ""; # dissociate it from this pkgnum
457       $cust->{'contact_id'} = 1; # set to the system default contact
458       $self->api_update("customers/$id", $cust);
459     }
460     # can and should delete contacts though
461     $self->api_delete("customercontacts/$contact_id");
462   }
463 }
464
465 ##############
466 # API ACCESS #
467 ##############
468
469 =item api_query RESOURCE, CONTENT
470
471 Makes a GET request to RESOURCE, the name of a resource type (like
472 'customers'), with query parameters in CONTENT, unpacks the embedded search
473 results, and returns them as a list.
474
475 Sipwise ignores invalid query parameters rather than throwing an error, so if
476 the parameters are misspelled or make no sense for this type of query, it will
477 probably return all of the objects.
478
479 =cut
480
481 sub api_query {
482   my $self = shift;
483   my ($resource, $content) = @_;
484   if ( ref $content eq 'HASH' ) {
485     $content = [ %$content ];
486   }
487   my $result = $self->api_request('GET', $resource, $content);
488   my @records;
489   # depaginate
490   while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) {
491     if ( ref($things) eq 'ARRAY' ) {
492       push @records, @$things;
493     } else {
494       push @records, $things;
495     }
496     if ( my $linknext = $result->{_links}{next} ) {
497       warn "$me continued at $linknext\n" if $DEBUG;
498       $result = $self->api_request('GET', $linknext);
499     } else {
500       last;
501     }
502   }
503   return @records;
504 }
505
506 =item api_create RESOURCE, CONTENT
507
508 Makes a POST request to RESOURCE, the name of a resource type (like
509 'customers'), to create a new object of that type. CONTENT must be a hashref of
510 the object's fields.
511
512 On success, will then fetch and return the newly created object. On failure,
513 will throw the "message" parameter from the request as an exception.
514
515 =cut
516
517 sub api_create {
518   my $self = shift;
519   my ($resource, $content) = @_;
520   my $result = $self->api_request('POST', $resource, $content);
521   if ( $result->{location} ) {
522     return $self->api_request('GET', $result->{location});
523   } else {
524     die $result->{message} . "\n";
525   }
526 }
527
528 =item api_update ENDPOINT, CONTENT
529
530 Makes a PUT request to ENDPOINT, the name of a specific record (like
531 'customers/11'), to replace it with the data in CONTENT (a hashref of the
532 object's fields). On failure, will throw an exception. On success,
533 returns nothing.
534
535 =cut
536
537 sub api_update {
538   my $self = shift;
539   my ($endpoint, $content) = @_;
540   my $result = $self->api_request('PUT', $endpoint, $content);
541   if ( $result->{message} ) {
542     die $result->{message} . "\n";
543   }
544   return;
545 }
546
547 =item api_delete ENDPOINT
548
549 Makes a DELETE request to ENDPOINT. On failure, will throw an exception.
550
551 =cut
552
553 sub api_delete {
554   my $self = shift;
555   my $endpoint = shift;
556   my $result = $self->api_request('DELETE', $endpoint);
557   if ( $result->{code} and $result->{code} eq '404' ) {
558     # special case: this is harmless. we tried to delete something and it
559     # was already gone.
560     warn "$me api_delete $endpoint: does not exist\n";
561     return;
562   } elsif ( $result->{message} ) {
563     die $result->{message} . "\n";
564   }
565   return;
566 }
567
568 =item api_request METHOD, ENDPOINT, CONTENT
569
570 Makes a REST request with HTTP method METHOD, to path ENDPOINT, with content
571 CONTENT. If METHOD is GET, the content can be an arrayref or hashref to append
572 as the query argument. If it's POST or PUT, the content will be JSON-serialized
573 and sent as the request body. If it's DELETE, content will be ignored.
574
575 =cut
576
577 sub api_request {
578   my $self = shift;
579   my ($method, $endpoint, $content) = @_;
580   $DEBUG ||= 1 if $self->option('debug');
581   my $url;
582   if ($endpoint =~ /^http/) {
583     # allow directly using URLs returned from the API
584     $url = $endpoint;
585   } else {
586     $endpoint =~ s[/api/][]; # allow using paths returned in Location headers
587     $url = 'https://' . $self->host . '/api/' . $endpoint;
588     $url .= '/' unless $url =~ m[/$];
589   }
590   my $request;
591   if ( lc($method) eq 'get' ) {
592     $url = URI->new($url);
593     $url->query_form($content);
594     $request = GET($url,
595       'Accept'        => 'application/json'
596     );
597   } elsif ( lc($method) eq 'post' ) {
598     $request = POST($url,
599       'Accept'        => 'application/json',
600       'Content'       => encode_json($content),
601       'Content-Type'  => 'application/json',
602     );
603   } elsif ( lc($method) eq 'put' ) {
604     $request = PUT($url,
605       'Accept'        => 'application/json',
606       'Content'       => encode_json($content),
607       'Content-Type'  => 'application/json',
608     );
609   } elsif ( lc($method) eq 'delete' ) {
610     $request = DELETE($url);
611   }
612
613   warn "$me $method $endpoint\n" if $DEBUG;
614   warn $request->as_string ."\n" if $DEBUG > 1;
615   my $response = $self->ua->request($request);
616   warn "$me received\n" . $response->as_string ."\n" if $DEBUG > 1;
617
618   my $decoded_response = {};
619   if ( $response->content ) {
620     local $@;
621     $decoded_response = eval { decode_json($response->content) };
622     if ( $@ ) {
623       # then it can't be parsed; probably a low-level error of some kind.
624       warn "$me Parse error.\n".$response->content."\n\n";
625       die "$me Parse error:".$response->content . "\n";
626     }
627   }
628   if ( $response->header('Location') ) {
629     $decoded_response->{location} = $response->header('Location');
630   }
631   return $decoded_response;
632 }
633
634 # a little false laziness with aradial.pm
635 sub host {
636   my $self = shift;
637   my $port = $self->option('port') || 1443;
638   $self->machine . ":$port";
639 }
640
641 sub ua {
642   my $self = shift;
643   $self->{_ua} ||= do {
644     my @opt;
645     if ( $self->option('ssl_no_verify') ) {
646       push @opt, ssl_opts => { verify_hostname => 0 };
647     }
648     my $ua = LWP::UserAgent->new(@opt);
649     $ua->credentials(
650       $self->host,
651       'api_admin_http',
652       $self->option('username'),
653       $self->option('password')
654     );
655     $ua;
656   }
657 }
658
659
660 1;