1 package FS::part_export::sipwise;
3 use base qw( FS::part_export );
6 use FS::Record qw(qsearch qsearchs dbh);
12 use HTTP::Request::Common qw(GET POST PUT DELETE);
13 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_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>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>
69 my($self, $svc_x) = (shift, shift);
72 my $role = $self->svc_role($svc_x);
73 if ( $role eq 'subscriber' ) {
75 eval { $self->insert_subscriber($svc_x) };
76 return "$me $@" if $@;
78 } elsif ( $role eq 'did' ) {
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);
88 eval { $self->replace_subscriber($subscriber) };
89 return "$me $@" if $@;
96 my ($self, $svc_new, $svc_old) = @_;
97 my $role = $self->svc_role($svc_new);
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) };
104 return "$me $@" if $@;
109 my ($self, $svc_x) = (shift, shift);
110 my $role = $self->svc_role($svc_x);
112 if ( $role eq 'subscriber' ) {
114 # no need to remove DIDs from it, just drop the subscriber record
115 eval { $self->delete_subscriber($svc_x) };
117 } elsif ( $role eq 'did' ) {
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);
124 eval { $self->delete_did($svc_x, $subscriber) };
127 return "$me $@" if $@;
135 my $role = $self->svc_role($svc_x);
136 return if $role ne 'subacct'; # can't suspend DIDs directly
138 my $error = $self->replace_subacct($svc_x, $svc_x); # will disable it
139 return "$me $error" if $error;
143 sub export_unsuspend {
146 my $role = $self->svc_role($svc_x);
147 return if $role ne 'subacct'; # can't suspend DIDs directly
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;
159 =item get_customer SERVICE
161 Returns the Sipwise customer record that should belong to SERVICE. This is
162 based on the pkgnum field.
169 my $pkgnum = $svc->cust_svc->pkgnum;
170 my $custid = "cust_pkg#$pkgnum";
172 my @cust = $self->api_query('customers', [ external_id => $custid ]);
173 warn "$me multiple customers for external_id $custid.\n" if scalar(@cust) > 1;
177 sub find_or_create_customer {
180 my $cust = $self->get_customer($svc);
181 return $cust if $cust;
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;
189 # find the billing profile
190 my ($billing_profile) = $self->api_query('billingprofiles',
192 'handle' => $self->option('billing_profile'),
193 'reseller_id' => $self->option('reseller_id'),
196 if (!$billing_profile) {
197 croak "can't find billing profile '". $self->option('billing_profile') . "'";
199 my $bpid = $billing_profile->{id};
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',
205 'city' => $cust_location->city,
206 'company' => $cust_main->company,
207 'country' => $cust_location->country,
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,
220 $cust = $self->api_create('customers',
222 'status' => 'active',
223 'type' => 'sipaccount',
224 'contact_id' => $contact->{id},
225 'external_id' => $custid,
226 'billing_profile_id' => $bpid,
237 =item find_or_create_domain DOMAIN
239 Returns the record for the domain object named DOMAIN. If necessary, will
244 sub find_or_create_domain {
246 my $domainname = shift;
247 my ($domain) = $self->api_query('domains', [ 'domain' => $domainname ]);
248 return $domain if $domain;
250 $self->api_create('domains',
252 'domain' => $domainname,
253 'reseller_id' => $self->option('reseller_id'),
262 =item get_subscriber SVC
264 Gets the subscriber record for SVC, if there is one.
272 my $svcnum = $svc->svcnum;
273 my $svcid = "svc_phone#$svcnum";
275 my $pkgnum = $svc->cust_svc->pkgnum;
276 my $custid = "cust_pkg#$pkgnum";
278 my @subscribers = grep { $_->{external_id} eq $svcid }
279 $self->api_query('subscribers',
280 [ 'customer_external_id' => $custid ]
282 warn "$me multiple subscribers for external_id $svcid.\n"
283 if scalar(@subscribers) > 1;
288 # internal method: find DIDs that forward to this service
290 sub did_numbers_for_svc {
294 my @possible_dids = qsearch({
295 'table' => 'svc_phone',
296 'hashref' => { 'forwarddst' => $svc->phonenum },
297 'order_by' => ' ORDER BY phonenum'
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' ) {
303 if ($did->countrycode) {
304 $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum);
307 my $country = $did->cust_svc->cust_pkg->cust_location->country;
308 $phonenum = Number::Phone->new($country, $did->phonenum);
311 croak "Can't process phonenum ".$did->countrycode . $did->phonenum;
314 { 'cc' => $phonenum->country_code,
315 'ac' => $phonenum->areacode,
316 'sn' => $phonenum->subscriber
323 sub insert_subscriber {
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);
332 my @numbers = $self->did_numbers_for_svc($svc);
333 my $first_number = shift @numbers;
335 my $subscriber = $self->api_create('subscribers',
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,
346 'username' => $svc->phonenum,
351 sub replace_subscriber {
355 my $svcid = "svc_phone#" . $svc->svcnum;
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);
361 my @numbers = $self->did_numbers_for_svc($svc);
362 my $first_number = shift @numbers;
364 my $subscriber = $self->get_subscriber($svc);
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);
373 $self->api_update("subscribers/$id",
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,
384 'username' => $svc->phonenum,
389 warn "$me subscriber not found for $svcid; creating new\n";
390 $self->insert_subscriber($svc);
394 sub delete_subscriber {
397 my $svcid = "svc_phone#" . $svc->svcnum;
398 my $pkgnum = $svc->cust_svc->pkgnum;
399 my $custid = "cust_pkg#$pkgnum";
401 my $subscriber = $self->get_subscriber($svc);
404 my $id = $subscriber->{id};
405 $self->api_delete("subscribers/$id");
407 warn "$me subscriber not found for $svcid (would be deleted)\n";
410 my (@other_subs) = $self->api_query('subscribers',
411 [ 'customer_external_id' => $custid ]
414 # then it's safe to remove the customer
415 my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]);
417 warn "$me customer not found for $custid\n";
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);
429 # can and should delete contacts though
430 $self->api_delete("customercontacts/$contact_id");
438 =item api_query RESOURCE, CONTENT
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.
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.
452 my ($resource, $content) = @_;
453 if ( ref $content eq 'HASH' ) {
454 $content = [ %$content ];
456 my $result = $self->api_request('GET', $resource, $content);
459 while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) {
460 if ( ref($things) eq 'ARRAY' ) {
461 push @records, @$things;
463 push @records, $things;
465 if ( my $linknext = $result->{_links}{next} ) {
466 warn "$me continued at $linknext\n" if $DEBUG;
467 $result = $self->api_request('GET', $linknext);
475 =item api_create RESOURCE, CONTENT
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
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.
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});
493 croak $result->{message};
497 =item api_update ENDPOINT, CONTENT
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,
508 my ($endpoint, $content) = @_;
509 my $result = $self->api_request('PUT', $endpoint, $content);
510 if ( $result->{message} ) {
511 croak $result->{message};
516 =item api_delete ENDPOINT
518 Makes a DELETE request to ENDPOINT. On failure, will throw an exception.
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
529 warn "$me api_delete $endpoint: does not exist\n";
531 } elsif ( $result->{message} ) {
532 croak $result->{message};
537 =item api_request METHOD, ENDPOINT, CONTENT
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.
548 my ($method, $endpoint, $content) = @_;
549 $DEBUG ||= 1 if $self->option('debug');
551 if ($endpoint =~ /^http/) {
552 # allow directly using URLs returned from the API
555 $endpoint =~ s[/api/][]; # allow using paths returned in Location headers
556 $url = 'https://' . $self->host . '/api/' . $endpoint;
557 $url .= '/' unless $url =~ m[/$];
560 if ( lc($method) eq 'get' ) {
561 $url = URI->new($url);
562 $url->query_form($content);
564 'Accept' => 'application/json'
566 } elsif ( lc($method) eq 'post' ) {
567 $request = POST($url,
568 'Accept' => 'application/json',
569 'Content' => encode_json($content),
570 'Content-Type' => 'application/json',
572 } elsif ( lc($method) eq 'put' ) {
574 'Accept' => 'application/json',
575 'Content' => encode_json($content),
576 'Content-Type' => 'application/json',
578 } elsif ( lc($method) eq 'delete' ) {
579 $request = DELETE($url);
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;
587 my $decoded_response = {};
588 if ( $response->content ) {
590 $decoded_response = eval { decode_json($response->content) };
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;
597 if ( $response->header('Location') ) {
598 $decoded_response->{location} = $response->header('Location');
600 return $decoded_response;
603 # a little false laziness with aradial.pm
606 my $port = $self->option('port') || 1443;
607 $self->machine . ":$port";
612 $self->{_ua} ||= do {
614 if ( $self->option('ssl_no_verify') ) {
615 push @opt, ssl_opts => { verify_hostname => 0 };
617 my $ua = LWP::UserAgent->new(@opt);
621 $self->option('username'),
622 $self->option('password')