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);
18 our $me = '[sipwise]';
21 tie my %options, 'Tie::IxHash',
22 'port' => { label => 'Port' },
23 'username' => { label => 'API username', },
24 'password' => { label => 'API password', },
25 'debug' => { label => 'Enable debugging', type => 'checkbox', value => 1 },
26 'billing_profile' => {
27 label => 'Billing profile handle',
30 'subscriber_profile_set' => {
31 label => 'Subscriber profile set name (optional)',
33 'reseller_id' => { label => 'Reseller ID' },
34 'ssl_no_verify' => { label => 'Skip SSL certificate validation',
39 tie my %roles, 'Tie::IxHash',
40 'subscriber' => { label => 'Subscriber',
44 'did' => { label => 'DID',
51 'svc' => [qw( svc_acct svc_phone )],
52 'desc' => 'Provision to a Sipwise sip:provider server',
53 'options' => \%options,
56 <P>Export to a <b>sip:provider</b> server.</P>
57 <P>This requires two service definitions to be configured on the same package:
59 <LI>An account service for a SIP client account ("subscriber"). The
60 <i>username</i> will be the SIP username. The <i>domsvc</i> should point
61 to a domain service to use as the SIP domain name.</LI>
62 <LI>A phone service for a DID. The <i>phonenum</i> here will be a PSTN
63 number. The <i>forward_svcnum</i> field should be set to the account that
64 will receive calls at this number.
71 my($self, $svc_x) = (shift, shift);
75 my $role = $self->svc_role($svc_x);
76 if ( $role eq 'subscriber' ) {
78 try { $self->insert_subscriber($svc_x) }
79 catch { $error = $_ };
81 } elsif ( $role eq 'did' ) {
83 try { $self->export_did($svc_x) }
84 catch { $error = $_ };
87 return "$me $error" if $error;
92 my ($self, $svc_new, $svc_old) = @_;
95 my $role = $self->svc_role($svc_new);
98 if ( $role eq 'subscriber' ) {
100 try { $self->replace_subscriber($svc_new, $svc_old) }
101 catch { $error = $_ };
103 } elsif ( $role eq 'did' ) {
105 try { $self->export_did($svc_new, $svc_old) }
106 catch { $error = $_ };
109 return "$me $error" if $error;
114 my ($self, $svc_x) = (shift, shift);
117 my $role = $self->svc_role($svc_x);
120 if ( $role eq 'subscriber' ) {
122 # no need to remove DIDs from it, just drop the subscriber record
123 try { $self->delete_subscriber($svc_x) }
124 catch { $error = $_ };
126 } elsif ( $role eq 'did' ) {
128 try { $self->export_did($svc_x) }
129 catch { $error = $_ };
132 return "$me $error" if $error;
136 # logic to set subscribers to locked/active is in replace_subscriber
141 my $role = $self->svc_role($svc_x);
143 if ( $role eq 'subscriber' ) {
144 try { $self->replace_subscriber($svc_x, $svc_x) }
145 catch { $error = $_ };
147 return "$me $error" if $error;
151 sub export_unsuspend {
154 my $role = $self->svc_role($svc_x);
156 if ( $role eq 'subscriber' ) {
157 $svc_x->set('unsuspended', 1);
158 try { $self->replace_subscriber($svc_x, $svc_x) }
159 catch { $error = $_ };
161 return "$me $error" if $error;
169 =item get_customer SERVICE
171 Returns the Sipwise customer record that should belong to SERVICE. This is
172 based on the pkgnum field.
179 my $pkgnum = $svc->cust_svc->pkgnum;
180 my $custid = "cust_pkg#$pkgnum";
182 my @cust = $self->api_query('customers', [ external_id => $custid ]);
183 warn "$me multiple customers for external_id $custid.\n" if scalar(@cust) > 1;
187 sub find_or_create_customer {
190 my $cust = $self->get_customer($svc);
191 return $cust if $cust;
193 my $cust_pkg = $svc->cust_svc->cust_pkg;
194 my $cust_main = $cust_pkg->cust_main;
195 my $cust_location = $cust_pkg->cust_location;
196 my ($email) = $cust_main->invoicing_list_emailonly;
197 die "Customer contact email required\n" if !$email;
198 my $custid = 'cust_pkg#' . $cust_pkg->pkgnum;
200 # find the billing profile
201 my ($billing_profile) = $self->api_query('billingprofiles',
203 'handle' => $self->option('billing_profile'),
204 'reseller_id' => $self->option('reseller_id'),
207 if (!$billing_profile) {
208 die "can't find billing profile '". $self->option('billing_profile') . "'\n";
210 my $bpid = $billing_profile->{id};
212 # contacts unfortunately have no searchable external_id or other field
213 # like that, so we can't go location -> package -> service
214 my $contact = $self->api_create('customercontacts',
216 'city' => $cust_location->city,
217 'company' => $cust_main->company,
218 'country' => $cust_location->country,
220 'faxnumber' => $cust_main->fax,
221 'firstname' => $cust_main->first,
222 'lastname' => $cust_main->last,
223 'mobilenumber' => $cust_main->mobile,
224 'phonenumber' => ($cust_main->daytime || $cust_main->night),
225 'postcode' => $cust_location->zip,
226 'reseller_id' => $self->option('reseller_id'),
227 'street' => $cust_location->address1,
231 $cust = $self->api_create('customers',
233 'status' => 'active',
234 'type' => 'sipaccount',
235 'contact_id' => $contact->{id},
236 'external_id' => $custid,
237 'billing_profile_id' => $bpid,
248 =item find_or_create_domain DOMAIN
250 Returns the record for the domain object named DOMAIN. If necessary, will
255 sub find_or_create_domain {
257 my $domainname = shift;
258 my ($domain) = $self->api_query('domains', [ 'domain' => $domainname ]);
259 return $domain if $domain;
261 $self->api_create('domains',
263 'domain' => $domainname,
264 'reseller_id' => $self->option('reseller_id'),
273 =item acct_for_did SVC_PHONE
275 Returns the subscriber svc_acct linked to SVC_PHONE.
281 my $svc_phone = shift;
282 my $svcnum = $svc_phone->forward_svcnum or return;
283 my $svc_acct = FS::svc_acct->by_key($svcnum) or return;
284 $self->svc_role($svc_acct) eq 'subscriber' or return;
288 =item export_did NEW, OLD
290 Refreshes the subscriber information for the service the DID was linked to
291 previously, and the one it's linked to now.
297 my ($new, $old) = @_;
298 if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) {
299 my $old_svc_acct = $self->acct_for_did($old);
300 $self->replace_subscriber( $old_svc_acct ) if $old_svc_acct;
302 my $new_svc_acct = $self->acct_for_did($new);
303 $self->replace_subscriber( $new_svc_acct ) if $new_svc_acct;
310 =item get_subscriber SVC
312 Gets the subscriber record for SVC, if there is one.
320 my $svcnum = $svc->svcnum;
321 my $svcid = "svc#$svcnum";
323 my $pkgnum = $svc->cust_svc->pkgnum;
324 my $custid = "cust_pkg#$pkgnum";
326 my @subscribers = grep { $_->{external_id} eq $svcid }
327 $self->api_query('subscribers',
328 [ 'customer_external_id' => $custid ]
330 warn "$me multiple subscribers for external_id $svcid.\n"
331 if scalar(@subscribers) > 1;
336 # internal method: find DIDs that forward to this service
338 sub did_numbers_for_svc {
343 'table' => 'svc_phone',
344 'hashref' => { 'forward_svcnum' => $svc->svcnum }
346 foreach my $did (@dids) {
347 # only include them if they're interesting to this export
348 if ( $self->svc_role($did) eq 'did' ) {
350 if ($did->countrycode) {
351 $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum);
354 my $country = $did->cust_svc->cust_pkg->cust_location->country;
355 $phonenum = Number::Phone->new($country, $did->phonenum);
358 die "Can't process phonenum ".$did->countrycode . $did->phonenum . "\n";
361 { 'cc' => $phonenum->country_code,
362 'ac' => $phonenum->areacode,
363 'sn' => $phonenum->subscriber
370 sub get_subscriber_profile_set_id {
372 if ( my $setname = $self->option('subscriber_profile_set') ) {
373 my ($set) = $self->api_query('subscriberprofilesets',
376 die "Subscriber profile set '$setname' not found" unless $set;
382 sub insert_subscriber {
386 my $cust = $self->find_or_create_customer($svc);
387 my $svcid = "svc#" . $svc->svcnum;
388 my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
389 $status = 'active' if $svc->get('unsuspended');
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 $profile_set_id = $self->get_subscriber_profile_set_id;
396 my $subscriber = $self->api_create('subscribers',
398 'alias_numbers' => \@numbers,
399 'customer_id' => $cust->{id},
400 'display_name' => $svc->finger,
401 'domain_id' => $domain->{id},
402 'external_id' => $svcid,
403 'password' => $svc->_password,
404 'primary_number' => $first_number,
405 'profile_set_id' => $profile_set_id,
407 'username' => $svc->username,
412 sub replace_subscriber {
415 my $old = shift || $svc->replace_old;
416 my $svcid = "svc#" . $svc->svcnum;
418 my $cust = $self->find_or_create_customer($svc);
419 my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
420 $status = 'active' if $svc->get('unsuspended');
421 my $domain = $self->find_or_create_domain($svc->domain);
423 my @numbers = $self->did_numbers_for_svc($svc);
424 my $first_number = shift @numbers;
426 my $subscriber = $self->get_subscriber($svc);
429 my $id = $subscriber->{id};
430 if ( $svc->username ne $old->username ) {
431 # have to delete and recreate
432 $self->api_delete("subscribers/$id");
433 $self->insert_subscriber($svc);
435 my $profile_set_id = $self->get_subscriber_profile_set_id;
436 $self->api_update("subscribers/$id",
438 'alias_numbers' => \@numbers,
439 'customer_id' => $cust->{id},
440 'display_name' => $svc->finger,
441 'domain_id' => $domain->{id},
442 'email' => $svc->email,
443 'external_id' => $svcid,
444 'password' => $svc->_password,
445 'primary_number' => $first_number,
446 'profile_set_id' => $profile_set_id,
448 'username' => $svc->username,
453 warn "$me subscriber not found for $svcid; creating new\n";
454 $self->insert_subscriber($svc);
458 sub delete_subscriber {
461 my $svcid = "svc#" . $svc->svcnum;
462 my $pkgnum = $svc->cust_svc->pkgnum;
463 my $custid = "cust_pkg#$pkgnum";
465 my $subscriber = $self->get_subscriber($svc);
468 my $id = $subscriber->{id};
469 $self->api_delete("subscribers/$id");
471 warn "$me subscriber not found for $svcid (would be deleted)\n";
474 my (@other_subs) = $self->api_query('subscribers',
475 [ 'customer_external_id' => $custid ]
478 # then it's safe to remove the customer
479 my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]);
481 warn "$me customer not found for $custid\n";
484 my $id = $cust->{id};
485 my $contact_id = $cust->{contact_id};
486 if ( $cust->{'status'} ne 'terminated' ) {
487 # can't delete customers, have to cancel them
488 $cust->{'status'} = 'terminated';
489 $cust->{'external_id'} = ""; # dissociate it from this pkgnum
490 $cust->{'contact_id'} = 1; # set to the system default contact
491 $self->api_update("customers/$id", $cust);
493 # can and should delete contacts though
494 $self->api_delete("customercontacts/$contact_id");
502 =item import_cdrs START, END
504 Retrieves CDRs for calls in the date range from START to END and inserts them
505 as a CDR batch. On success, returns a new cdr_batch object. On failure,
506 returns an error message. If there are no new CDRs, returns nothing.
511 my ($self, $start, $end) = @_;
515 my $oldAutoCommit = $FS::UID::AutoCommit;
516 local $FS::UID::AutoCommit = 0;
518 ($start, $end) = ($end, $start) if $end < $start;
519 $start = DateTime->from_epoch(epoch => $start, time_zone => 'local');
520 $end = DateTime->from_epoch(epoch => $end, time_zone => 'local');
521 $end->subtract(seconds => 1); # filter by >= and <= only, so...
523 # a little different from the usual: we have to fetch these subscriber by
524 # subscriber, not all at once.
526 'table' => 'svc_acct',
527 'addl_from' => ' JOIN cust_svc USING (svcnum)' .
528 ' JOIN export_svc USING (svcpart)',
529 'extra_sql' => ' WHERE export_svc.role = \'subscriber\''.
530 ' AND export_svc.exportnum = '.$self->exportnum
533 my @args = ( 'start_ge' => $start->iso8601,
534 'start_le' => $end->iso8601,
538 SVC: foreach my $svc (@svcs) {
539 my $subscriber = $self->get_subscriber($svc);
541 warn "$me user ".$svc->label." is not configured on the SIP server.\n";
544 my $id = $subscriber->{id};
547 # alias_field tells "calllists" which field from the source and
548 # destination to use as the "own_cli" and "other_cli" of the call.
549 # "user" = username@domain.
550 @calls = $self->api_query('calllists', [
551 'subscriber_id' => $id,
552 'alias_field' => 'user',
556 $error = "$me $_ (retrieving records for ".$svc->label.")";
559 if (@calls and !$cdr_batch) {
560 # create a cdr_batch if needed
561 my $cdrbatchname = 'sipwise-' . $self->exportnum . '-' . $end->epoch;
562 $cdr_batch = FS::cdr_batch->new({ cdrbatch => $cdrbatchname });
563 $error = $cdr_batch->insert;
568 foreach my $c (@calls) {
569 # avoid double-importing
570 my $uniqueid = $c->{call_id};
571 if ( FS::cdr->row_exists("uniqueid = ?", $uniqueid) ) {
572 warn "skipped call with uniqueid = '$uniqueid' (already imported)\n"
576 my $src = $c->{own_cli};
577 my $dst = $c->{other_cli};
578 if ( $c->{direction} eq 'in' ) { # then reverse them
579 ($src, $dst) = ($dst, $src);
581 # parse duration from H:MM:SS format
583 if ( $c->{duration} =~ /^(\d+):(\d+):(\d+)$/ ) {
584 $duration = $3 + (60 * $2) + (3600 * $1);
586 $error = "call $uniqueid: unparseable duration '".$c->{duration}."'";
589 # use the username@domain label for src and/or dst if possible
590 my $cdr = FS::cdr->new({
591 uniqueid => $uniqueid,
592 upstream_price => $c->{customer_cost},
593 startdate => parse_datetime($c->{start_time}),
594 disposition => $c->{status},
595 duration => $duration,
596 billsec => $duration,
600 $error ||= $cdr->insert;
606 dbh->rollback if $oldAutoCommit;
608 } elsif ( $cdr_batch ) {
609 dbh->commit if $oldAutoCommit;
620 =item api_query RESOURCE, CONTENT
622 Makes a GET request to RESOURCE, the name of a resource type (like
623 'customers'), with query parameters in CONTENT, unpacks the embedded search
624 results, and returns them as a list.
626 Sipwise ignores invalid query parameters rather than throwing an error, so if
627 the parameters are misspelled or make no sense for this type of query, it will
628 probably return all of the objects.
634 my ($resource, $content) = @_;
635 if ( ref $content eq 'HASH' ) {
636 $content = [ %$content ];
639 push @$content, ('rows' => 100, 'page' => 1); # 'page' is always last
640 my $result = $self->api_request('GET', $resource, $content);
643 while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) {
644 if ( ref($things) eq 'ARRAY' ) {
645 push @records, @$things;
647 push @records, $things;
649 if ( my $linknext = $result->{_links}{next} ) {
650 # unfortunately their HAL isn't entirely functional
651 # it returns "next" links that contain "page" and "rows" but no other
652 # parameters. so just count the pages:
654 $content->[-1] = $page;
656 warn "$me continued: $page\n" if $DEBUG;
657 $result = $self->api_request('GET', $resource, $content);
665 =item api_create RESOURCE, CONTENT
667 Makes a POST request to RESOURCE, the name of a resource type (like
668 'customers'), to create a new object of that type. CONTENT must be a hashref of
671 On success, will then fetch and return the newly created object. On failure,
672 will throw the "message" parameter from the request as an exception.
678 my ($resource, $content) = @_;
679 my $result = $self->api_request('POST', $resource, $content);
680 if ( $result->{location} ) {
681 return $self->api_request('GET', $result->{location});
683 die $result->{message} . "\n";
687 =item api_update ENDPOINT, CONTENT
689 Makes a PUT request to ENDPOINT, the name of a specific record (like
690 'customers/11'), to replace it with the data in CONTENT (a hashref of the
691 object's fields). On failure, will throw an exception. On success,
698 my ($endpoint, $content) = @_;
699 my $result = $self->api_request('PUT', $endpoint, $content);
700 if ( $result->{message} ) {
701 die $result->{message} . "\n";
706 =item api_delete ENDPOINT
708 Makes a DELETE request to ENDPOINT. On failure, will throw an exception.
714 my $endpoint = shift;
715 my $result = $self->api_request('DELETE', $endpoint);
716 if ( $result->{code} and $result->{code} eq '404' ) {
717 # special case: this is harmless. we tried to delete something and it
719 warn "$me api_delete $endpoint: does not exist\n";
721 } elsif ( $result->{message} ) {
722 die $result->{message} . "\n";
727 =item api_request METHOD, ENDPOINT, CONTENT
729 Makes a REST request with HTTP method METHOD, to path ENDPOINT, with content
730 CONTENT. If METHOD is GET, the content can be an arrayref or hashref to append
731 as the query argument. If it's POST or PUT, the content will be JSON-serialized
732 and sent as the request body. If it's DELETE, content will be ignored.
738 my ($method, $endpoint, $content) = @_;
739 $DEBUG ||= 1 if $self->option('debug');
741 if ($endpoint =~ /^http/) {
742 # allow directly using URLs returned from the API
745 $endpoint =~ s[/api/][]; # allow using paths returned in Location headers
746 $url = 'https://' . $self->host . '/api/' . $endpoint;
747 $url .= '/' unless $url =~ m[/$];
750 if ( lc($method) eq 'get' ) {
751 $url = URI->new($url);
752 $url->query_form($content);
754 'Accept' => 'application/json'
756 } elsif ( lc($method) eq 'post' ) {
757 $request = POST($url,
758 'Accept' => 'application/json',
759 'Content' => encode_json($content),
760 'Content-Type' => 'application/json',
762 } elsif ( lc($method) eq 'put' ) {
764 'Accept' => 'application/json',
765 'Content' => encode_json($content),
766 'Content-Type' => 'application/json',
768 } elsif ( lc($method) eq 'delete' ) {
769 $request = DELETE($url);
772 warn "$me $method $endpoint\n" if $DEBUG;
773 warn $request->as_string ."\n" if $DEBUG > 1;
774 my $response = $self->ua->request($request);
775 warn "$me received\n" . $response->as_string ."\n" if $DEBUG > 1;
777 my $decoded_response = {};
778 if ( $response->content ) {
780 $decoded_response = eval { decode_json($response->content) };
782 # then it can't be parsed; probably a low-level error of some kind.
783 warn "$me Parse error.\n".$response->content."\n\n";
784 die "$me Parse error:".$response->content . "\n";
787 if ( $response->header('Location') ) {
788 $decoded_response->{location} = $response->header('Location');
790 return $decoded_response;
793 # a little false laziness with aradial.pm
796 my $port = $self->option('port') || 1443;
797 $self->machine . ":$port";
802 $self->{_ua} ||= do {
804 if ( $self->option('ssl_no_verify') ) {
805 push @opt, ssl_opts => {
806 verify_hostname => 0,
807 SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
810 my $ua = LWP::UserAgent->new(@opt);
814 $self->option('username'),
815 $self->option('password')