1 package FS::part_export::sipwise;
3 use base qw( FS::part_export );
6 use FS::Record qw(qsearch qsearchs dbh);
11 use HTTP::Request::Common qw(GET POST PUT DELETE);
12 use FS::Misc::DateTime qw(parse_datetime);
17 our $me = '[sipwise]';
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
29 'reseller_id' => { label => 'Reseller ID' },
30 'ssl_no_verify' => { label => 'Skip SSL certificate validation',
35 tie my %roles, 'Tie::IxHash',
36 'subscriber' => { label => 'Subscriber',
40 'did' => { label => 'DID',
47 'svc' => [qw( svc_acct svc_phone )],
48 'desc' => 'Provision to a Sipwise sip:provider server',
49 'options' => \%options,
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:
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.
69 my($self, $svc_x) = (shift, shift);
72 my $role = $self->svc_role($svc_x);
73 if ( $role eq 'subscriber' ) {
75 try { $self->insert_subscriber($svc_x) }
76 catch { $error = $_ };
78 } elsif ( $role eq 'did' ) {
80 try { $self->export_did($svc_x) }
81 catch { $error = $_ };
84 return "$me $error" if $error;
89 my ($self, $svc_new, $svc_old) = @_;
90 my $role = $self->svc_role($svc_new);
93 if ( $role eq 'subscriber' ) {
95 try { $self->replace_subscriber($svc_new, $svc_old) }
96 catch { $error = $_ };
98 } elsif ( $role eq 'did' ) {
100 try { $self->export_did($svc_new, $svc_old) }
101 catch { $error = $_ };
104 return "$me $error" if $error;
109 my ($self, $svc_x) = (shift, shift);
110 my $role = $self->svc_role($svc_x);
113 if ( $role eq 'subscriber' ) {
115 # no need to remove DIDs from it, just drop the subscriber record
116 try { $self->delete_subscriber($svc_x) }
117 catch { $error = $_ };
119 } elsif ( $role eq 'did' ) {
121 try { $self->export_did($svc_x) }
122 catch { $error = $_ };
125 return "$me $error" if $error;
133 my $role = $self->svc_role($svc_x);
134 return if $role ne 'subacct'; # can't suspend DIDs directly
136 my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
137 return "$me $error" if $error;
141 sub export_unsuspend {
144 my $role = $self->svc_role($svc_x);
145 return if $role ne 'subacct'; # can't suspend DIDs directly
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;
157 =item get_customer SERVICE
159 Returns the Sipwise customer record that should belong to SERVICE. This is
160 based on the pkgnum field.
167 my $pkgnum = $svc->cust_svc->pkgnum;
168 my $custid = "cust_pkg#$pkgnum";
170 my @cust = $self->api_query('customers', [ external_id => $custid ]);
171 warn "$me multiple customers for external_id $custid.\n" if scalar(@cust) > 1;
175 sub find_or_create_customer {
178 my $cust = $self->get_customer($svc);
179 return $cust if $cust;
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;
187 # find the billing profile
188 my ($billing_profile) = $self->api_query('billingprofiles',
190 'handle' => $self->option('billing_profile'),
191 'reseller_id' => $self->option('reseller_id'),
194 if (!$billing_profile) {
195 die "can't find billing profile '". $self->option('billing_profile') . "'\n";
197 my $bpid = $billing_profile->{id};
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',
203 'city' => $cust_location->city,
204 'company' => $cust_main->company,
205 'country' => $cust_location->country,
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,
218 $cust = $self->api_create('customers',
220 'status' => 'active',
221 'type' => 'sipaccount',
222 'contact_id' => $contact->{id},
223 'external_id' => $custid,
224 'billing_profile_id' => $bpid,
235 =item find_or_create_domain DOMAIN
237 Returns the record for the domain object named DOMAIN. If necessary, will
242 sub find_or_create_domain {
244 my $domainname = shift;
245 my ($domain) = $self->api_query('domains', [ 'domain' => $domainname ]);
246 return $domain if $domain;
248 $self->api_create('domains',
250 'domain' => $domainname,
251 'reseller_id' => $self->option('reseller_id'),
260 =item acct_for_did SVC_PHONE
262 Returns the subscriber svc_acct linked to SVC_PHONE.
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;
275 =item export_did NEW, OLD
277 Refreshes the subscriber information for the service the DID was linked to
278 previously, and the one it's linked to now.
284 my ($new, $old) = @_;
285 if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) {
286 $self->replace_subscriber( $self->acct_for_did($old) );
288 $self->replace_subscriber( $self->acct_for_did($new) );
295 =item get_subscriber SVC
297 Gets the subscriber record for SVC, if there is one.
305 my $svcnum = $svc->svcnum;
306 my $svcid = "svc#$svcnum";
308 my $pkgnum = $svc->cust_svc->pkgnum;
309 my $custid = "cust_pkg#$pkgnum";
311 my @subscribers = grep { $_->{external_id} eq $svcid }
312 $self->api_query('subscribers',
313 [ 'customer_external_id' => $custid ]
315 warn "$me multiple subscribers for external_id $svcid.\n"
316 if scalar(@subscribers) > 1;
321 # internal method: find DIDs that forward to this service
323 sub did_numbers_for_svc {
328 'table' => 'svc_phone',
329 'hashref' => { 'forward_svcnum' => $svc->svcnum }
331 foreach my $did (@dids) {
332 # only include them if they're interesting to this export
333 if ( $self->svc_role($did) eq 'did' ) {
335 if ($did->countrycode) {
336 $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum);
339 my $country = $did->cust_svc->cust_pkg->cust_location->country;
340 $phonenum = Number::Phone->new($country, $did->phonenum);
343 die "Can't process phonenum ".$did->countrycode . $did->phonenum . "\n";
346 { 'cc' => $phonenum->country_code,
347 'ac' => $phonenum->areacode,
348 'sn' => $phonenum->subscriber
355 sub insert_subscriber {
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);
364 my @numbers = $self->did_numbers_for_svc($svc);
365 my $first_number = shift @numbers;
367 my $subscriber = $self->api_create('subscribers',
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,
377 'username' => $svc->username,
382 sub replace_subscriber {
385 my $old = shift || $svc->replace_old;
386 my $svcid = "svc#" . $svc->svcnum;
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);
392 my @numbers = $self->did_numbers_for_svc($svc);
393 my $first_number = shift @numbers;
395 my $subscriber = $self->get_subscriber($svc);
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);
404 $self->api_update("subscribers/$id",
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,
415 'username' => $svc->username,
420 warn "$me subscriber not found for $svcid; creating new\n";
421 $self->insert_subscriber($svc);
425 sub delete_subscriber {
428 my $svcid = "svc#" . $svc->svcnum;
429 my $pkgnum = $svc->cust_svc->pkgnum;
430 my $custid = "cust_pkg#$pkgnum";
432 my $subscriber = $self->get_subscriber($svc);
435 my $id = $subscriber->{id};
436 $self->api_delete("subscribers/$id");
438 warn "$me subscriber not found for $svcid (would be deleted)\n";
441 my (@other_subs) = $self->api_query('subscribers',
442 [ 'customer_external_id' => $custid ]
445 # then it's safe to remove the customer
446 my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]);
448 warn "$me customer not found for $custid\n";
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);
460 # can and should delete contacts though
461 $self->api_delete("customercontacts/$contact_id");
469 =item api_query RESOURCE, CONTENT
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.
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.
483 my ($resource, $content) = @_;
484 if ( ref $content eq 'HASH' ) {
485 $content = [ %$content ];
487 my $result = $self->api_request('GET', $resource, $content);
490 while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) {
491 if ( ref($things) eq 'ARRAY' ) {
492 push @records, @$things;
494 push @records, $things;
496 if ( my $linknext = $result->{_links}{next} ) {
497 warn "$me continued at $linknext\n" if $DEBUG;
498 $result = $self->api_request('GET', $linknext);
506 =item api_create RESOURCE, CONTENT
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
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.
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});
524 die $result->{message} . "\n";
528 =item api_update ENDPOINT, CONTENT
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,
539 my ($endpoint, $content) = @_;
540 my $result = $self->api_request('PUT', $endpoint, $content);
541 if ( $result->{message} ) {
542 die $result->{message} . "\n";
547 =item api_delete ENDPOINT
549 Makes a DELETE request to ENDPOINT. On failure, will throw an exception.
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
560 warn "$me api_delete $endpoint: does not exist\n";
562 } elsif ( $result->{message} ) {
563 die $result->{message} . "\n";
568 =item api_request METHOD, ENDPOINT, CONTENT
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.
579 my ($method, $endpoint, $content) = @_;
580 $DEBUG ||= 1 if $self->option('debug');
582 if ($endpoint =~ /^http/) {
583 # allow directly using URLs returned from the API
586 $endpoint =~ s[/api/][]; # allow using paths returned in Location headers
587 $url = 'https://' . $self->host . '/api/' . $endpoint;
588 $url .= '/' unless $url =~ m[/$];
591 if ( lc($method) eq 'get' ) {
592 $url = URI->new($url);
593 $url->query_form($content);
595 'Accept' => 'application/json'
597 } elsif ( lc($method) eq 'post' ) {
598 $request = POST($url,
599 'Accept' => 'application/json',
600 'Content' => encode_json($content),
601 'Content-Type' => 'application/json',
603 } elsif ( lc($method) eq 'put' ) {
605 'Accept' => 'application/json',
606 'Content' => encode_json($content),
607 'Content-Type' => 'application/json',
609 } elsif ( lc($method) eq 'delete' ) {
610 $request = DELETE($url);
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;
618 my $decoded_response = {};
619 if ( $response->content ) {
621 $decoded_response = eval { decode_json($response->content) };
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";
628 if ( $response->header('Location') ) {
629 $decoded_response->{location} = $response->header('Location');
631 return $decoded_response;
634 # a little false laziness with aradial.pm
637 my $port = $self->option('port') || 1443;
638 $self->machine . ":$port";
643 $self->{_ua} ||= do {
645 if ( $self->option('ssl_no_verify') ) {
646 push @opt, ssl_opts => { verify_hostname => 0 };
648 my $ua = LWP::UserAgent->new(@opt);
652 $self->option('username'),
653 $self->option('password')