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