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);
19 our $me = '[sipwise]';
22 tie my %options, 'Tie::IxHash',
23 'port' => { label => 'Port' },
24 'username' => { label => 'API username', },
25 'password' => { label => 'API password', },
26 'debug' => { label => 'Enable debugging', type => 'checkbox', value => 1 },
27 'billing_profile' => {
28 label => 'Billing profile handle',
31 'subscriber_profile_set' => {
32 label => 'Subscriber profile set name (optional)',
34 'reseller_id' => { label => 'Reseller ID' },
35 'ssl_no_verify' => { label => 'Skip SSL certificate validation',
40 tie my %roles, 'Tie::IxHash',
41 'subscriber' => { label => 'Subscriber',
45 'did' => { label => 'DID',
52 'svc' => [qw( svc_acct svc_phone )],
53 'desc' => 'Provision to a Sipwise sip:provider server',
54 'options' => \%options,
57 <P>Export to a <b>sip:provider</b> server.</P>
58 <P>This requires two service definitions to be configured on the same package:
60 <LI>An account service for a SIP client account ("subscriber"). The
61 <i>username</i> will be the SIP username. The <i>domsvc</i> should point
62 to a domain service to use as the SIP domain name.</LI>
63 <LI>A phone service for a DID. The <i>phonenum</i> here will be a PSTN
64 number. The <i>forward_svcnum</i> field should be set to the account that
65 will receive calls at this number.
72 my($self, $svc_x) = (shift, shift);
76 my $role = $self->svc_role($svc_x);
77 if ( $role eq 'subscriber' ) {
79 try { $self->insert_subscriber($svc_x) }
80 catch { $error = $_ };
82 } elsif ( $role eq 'did' ) {
84 try { $self->export_did($svc_x) }
85 catch { $error = $_ };
88 return "$me $error" if $error;
93 my ($self, $svc_new, $svc_old) = @_;
96 my $role = $self->svc_role($svc_new);
99 if ( $role eq 'subscriber' ) {
101 try { $self->replace_subscriber($svc_new, $svc_old) }
102 catch { $error = $_ };
104 } elsif ( $role eq 'did' ) {
106 try { $self->export_did($svc_new, $svc_old) }
107 catch { $error = $_ };
110 return "$me $error" if $error;
115 my ($self, $svc_x) = (shift, shift);
118 my $role = $self->svc_role($svc_x);
121 if ( $role eq 'subscriber' ) {
123 # no need to remove DIDs from it, just drop the subscriber record
124 try { $self->delete_subscriber($svc_x) }
125 catch { $error = $_ };
127 } elsif ( $role eq 'did' ) {
129 try { $self->export_did($svc_x) }
130 catch { $error = $_ };
133 return "$me $error" if $error;
137 # logic to set subscribers to locked/active is in replace_subscriber
139 sub _export_suspend {
142 my $role = $self->svc_role($svc_x);
144 if ( $role eq 'subscriber' ) {
145 try { $self->replace_subscriber($svc_x, $svc_x) }
146 catch { $error = $_ };
148 return "$me $error" if $error;
152 sub _export_unsuspend {
155 my $role = $self->svc_role($svc_x);
157 if ( $role eq 'subscriber' ) {
158 $svc_x->set('unsuspended', 1);
159 try { $self->replace_subscriber($svc_x, $svc_x) }
160 catch { $error = $_ };
162 return "$me $error" if $error;
170 =item get_customer SERVICE
172 Returns the Sipwise customer record that should belong to SERVICE. This is
173 based on the pkgnum field.
180 my $pkgnum = $svc->cust_svc->pkgnum;
181 my $custid = "cust_pkg#$pkgnum";
183 my @cust = $self->api_query('customers', [ external_id => $custid ]);
184 warn "$me multiple customers for external_id $custid.\n" if scalar(@cust) > 1;
188 sub find_or_create_customer {
191 my $cust = $self->get_customer($svc);
192 return $cust if $cust;
194 my $cust_pkg = $svc->cust_svc->cust_pkg;
195 my $cust_main = $cust_pkg->cust_main;
196 my $cust_location = $cust_pkg->cust_location;
197 my ($email) = $cust_main->invoicing_list_emailonly;
198 die "Customer contact email required\n" if !$email;
199 my $custid = 'cust_pkg#' . $cust_pkg->pkgnum;
201 # find the billing profile
202 my ($billing_profile) = $self->api_query('billingprofiles',
204 'handle' => $self->option('billing_profile'),
205 'reseller_id' => $self->option('reseller_id'),
208 if (!$billing_profile) {
209 die "can't find billing profile '". $self->option('billing_profile') . "'\n";
211 my $bpid = $billing_profile->{id};
213 # contacts unfortunately have no searchable external_id or other field
214 # like that, so we can't go location -> package -> service
215 my $contact = $self->api_create('customercontacts',
217 'city' => $cust_location->city,
218 'company' => $cust_main->company,
219 'country' => $cust_location->country,
221 'faxnumber' => $cust_main->fax,
222 'firstname' => $cust_main->first,
223 'lastname' => $cust_main->last,
224 'mobilenumber' => $cust_main->mobile,
225 'phonenumber' => ($cust_main->daytime || $cust_main->night),
226 'postcode' => $cust_location->zip,
227 'reseller_id' => $self->option('reseller_id'),
228 'street' => $cust_location->address1,
232 $cust = $self->api_create('customers',
234 'status' => 'active',
235 'type' => 'sipaccount',
236 'contact_id' => $contact->{id},
237 'external_id' => $custid,
238 'billing_profile_id' => $bpid,
249 =item find_or_create_domain DOMAIN
251 Returns the record for the domain object named DOMAIN. If necessary, will
256 sub find_or_create_domain {
258 my $domainname = shift;
259 my ($domain) = $self->api_query('domains', [ 'domain' => $domainname ]);
260 return $domain if $domain;
262 $self->api_create('domains',
264 'domain' => $domainname,
265 'reseller_id' => $self->option('reseller_id'),
274 =item acct_for_did SVC_PHONE
276 Returns the subscriber svc_acct linked to SVC_PHONE.
282 my $svc_phone = shift;
283 my $svcnum = $svc_phone->forward_svcnum or return;
284 my $svc_acct = FS::svc_acct->by_key($svcnum) or return;
285 $self->svc_role($svc_acct) eq 'subscriber' or return;
289 =item export_did NEW, OLD
291 Refreshes the subscriber information for the service the DID was linked to
292 previously, and the one it's linked to now.
298 my ($new, $old) = @_;
300 if ( $FS::svc_Common::noexport_hack ) {
301 carp 'export_did() suppressed by noexport_hack'
302 if $self->option('debug') || $DEBUG;
306 if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) {
307 my $old_svc_acct = $self->acct_for_did($old);
308 $self->replace_subscriber( $old_svc_acct ) if $old_svc_acct;
310 my $new_svc_acct = $self->acct_for_did($new);
311 $self->replace_subscriber( $new_svc_acct ) if $new_svc_acct;
318 =item get_subscriber SVC
320 Gets the subscriber record for SVC, if there is one.
328 my $svcnum = $svc->svcnum;
329 my $svcid = "svc#$svcnum";
331 my $pkgnum = $svc->cust_svc->pkgnum;
332 my $custid = "cust_pkg#$pkgnum";
334 my @subscribers = grep { $_->{external_id} eq $svcid }
335 $self->api_query('subscribers',
336 [ 'customer_external_id' => $custid ]
338 warn "$me multiple subscribers for external_id $svcid.\n"
339 if scalar(@subscribers) > 1;
344 # internal method: find DIDs that forward to this service
346 sub did_numbers_for_svc {
351 'table' => 'svc_phone',
352 'hashref' => { 'forward_svcnum' => $svc->svcnum }
354 foreach my $did (@dids) {
355 # only include them if they're interesting to this export
356 if ( $self->svc_role($did) eq 'did' ) {
358 if ($did->countrycode) {
359 $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum);
362 my $country = $did->cust_svc->cust_pkg->cust_location->country;
363 $phonenum = Number::Phone->new($country, $did->phonenum);
366 die "Can't process phonenum ".$did->countrycode . $did->phonenum . "\n";
369 { 'cc' => $phonenum->country_code,
370 'ac' => $phonenum->areacode,
371 'sn' => $phonenum->subscriber
378 sub get_subscriber_profile_set_id {
380 if ( my $setname = $self->option('subscriber_profile_set') ) {
381 my ($set) = $self->api_query('subscriberprofilesets',
384 die "Subscriber profile set '$setname' not found" unless $set;
390 sub insert_subscriber {
394 my $cust = $self->find_or_create_customer($svc);
395 my $svcid = "svc#" . $svc->svcnum;
396 my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
397 $status = 'active' if $svc->get('unsuspended');
398 my $domain = $self->find_or_create_domain($svc->domain);
400 my @numbers = $self->did_numbers_for_svc($svc);
401 my $first_number = shift @numbers;
403 my $profile_set_id = $self->get_subscriber_profile_set_id;
404 my $subscriber = $self->api_create('subscribers',
406 'alias_numbers' => \@numbers,
407 'customer_id' => $cust->{id},
408 'display_name' => $svc->finger,
409 'domain_id' => $domain->{id},
410 'external_id' => $svcid,
411 'password' => $svc->_password,
412 'primary_number' => $first_number,
413 'profile_set_id' => $profile_set_id,
415 'username' => $svc->username,
420 sub replace_subscriber {
423 my $old = shift || $svc->replace_old;
424 my $svcid = "svc#" . $svc->svcnum;
426 my $cust = $self->find_or_create_customer($svc);
427 my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
428 $status = 'active' if $svc->get('unsuspended');
429 my $domain = $self->find_or_create_domain($svc->domain);
431 my @numbers = $self->did_numbers_for_svc($svc);
432 my $first_number = shift @numbers;
434 my $subscriber = $self->get_subscriber($svc);
437 my $id = $subscriber->{id};
438 if ( $svc->username ne $old->username ) {
439 # have to delete and recreate
440 $self->api_delete("subscribers/$id");
441 $self->insert_subscriber($svc);
443 my $profile_set_id = $self->get_subscriber_profile_set_id;
444 $self->api_update("subscribers/$id",
446 'alias_numbers' => \@numbers,
447 'customer_id' => $cust->{id},
448 'display_name' => $svc->finger,
449 'domain_id' => $domain->{id},
450 'email' => $svc->email,
451 'external_id' => $svcid,
452 'password' => $svc->_password,
453 'primary_number' => $first_number,
454 'profile_set_id' => $profile_set_id,
456 'username' => $svc->username,
461 warn "$me subscriber not found for $svcid; creating new\n";
462 $self->insert_subscriber($svc);
466 sub delete_subscriber {
469 my $svcid = "svc#" . $svc->svcnum;
470 my $pkgnum = $svc->cust_svc->pkgnum;
471 my $custid = "cust_pkg#$pkgnum";
473 my $subscriber = $self->get_subscriber($svc);
476 my $id = $subscriber->{id};
477 $self->api_delete("subscribers/$id");
479 warn "$me subscriber not found for $svcid (would be deleted)\n";
482 my (@other_subs) = $self->api_query('subscribers',
483 [ 'customer_external_id' => $custid ]
486 # then it's safe to remove the customer
487 my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]);
489 warn "$me customer not found for $custid\n";
492 my $id = $cust->{id};
493 my $contact_id = $cust->{contact_id};
494 if ( $cust->{'status'} ne 'terminated' ) {
495 # can't delete customers, have to cancel them
496 $cust->{'status'} = 'terminated';
497 $cust->{'external_id'} = ""; # dissociate it from this pkgnum
498 $cust->{'contact_id'} = 1; # set to the system default contact
499 $self->api_update("customers/$id", $cust);
501 # can and should delete contacts though
502 $self->api_delete("customercontacts/$contact_id");
510 =item import_cdrs START, END
512 Retrieves CDRs for calls in the date range from START to END and inserts them
513 as a CDR batch. On success, returns a new cdr_batch object. On failure,
514 returns an error message. If there are no new CDRs, returns nothing.
519 my ($self, $start, $end) = @_;
523 my $oldAutoCommit = $FS::UID::AutoCommit;
524 local $FS::UID::AutoCommit = 0;
526 ($start, $end) = ($end, $start) if $end < $start;
527 $start = DateTime->from_epoch(epoch => $start, time_zone => 'local');
528 $end = DateTime->from_epoch(epoch => $end, time_zone => 'local');
529 $end->subtract(seconds => 1); # filter by >= and <= only, so...
531 # a little different from the usual: we have to fetch these subscriber by
532 # subscriber, not all at once.
534 'table' => 'svc_acct',
535 'addl_from' => ' JOIN cust_svc USING (svcnum)' .
536 ' JOIN export_svc USING (svcpart)',
537 'extra_sql' => ' WHERE export_svc.role = \'subscriber\''.
538 ' AND export_svc.exportnum = '.$self->exportnum
541 my @args = ( 'start_ge' => $start->iso8601,
542 'start_le' => $end->iso8601,
546 SVC: foreach my $svc (@svcs) {
547 my $subscriber = $self->get_subscriber($svc);
549 warn "$me user ".$svc->label." is not configured on the SIP server.\n";
552 my $id = $subscriber->{id};
555 # alias_field tells "calllists" which field from the source and
556 # destination to use as the "own_cli" and "other_cli" of the call.
557 # "user" = username@domain.
558 @calls = $self->api_query('calllists', [
559 'subscriber_id' => $id,
560 'alias_field' => 'user',
564 $error = "$me $_ (retrieving records for ".$svc->label.")";
567 if (@calls and !$cdr_batch) {
568 # create a cdr_batch if needed
569 my $cdrbatchname = 'sipwise-' . $self->exportnum . '-' . $end->epoch;
570 $cdr_batch = FS::cdr_batch->new({ cdrbatch => $cdrbatchname });
571 $error = $cdr_batch->insert;
576 foreach my $c (@calls) {
577 # avoid double-importing
578 my $uniqueid = $c->{call_id};
579 if ( FS::cdr->row_exists("uniqueid = ?", $uniqueid) ) {
580 warn "skipped call with uniqueid = '$uniqueid' (already imported)\n"
584 my $src = $c->{own_cli};
585 my $dst = $c->{other_cli};
586 if ( $c->{direction} eq 'in' ) { # then reverse them
587 ($src, $dst) = ($dst, $src);
589 # parse duration from H:MM:SS format
591 if ( $c->{duration} =~ /^(\d+):(\d+):(\d+)$/ ) {
592 $duration = $3 + (60 * $2) + (3600 * $1);
594 $error = "call $uniqueid: unparseable duration '".$c->{duration}."'";
597 # use the username@domain label for src and/or dst if possible
598 my $cdr = FS::cdr->new({
599 uniqueid => $uniqueid,
600 upstream_price => $c->{customer_cost},
601 startdate => parse_datetime($c->{start_time}),
602 disposition => $c->{status},
603 duration => $duration,
604 billsec => $duration,
608 $error ||= $cdr->insert;
614 dbh->rollback if $oldAutoCommit;
616 } elsif ( $cdr_batch ) {
617 dbh->commit if $oldAutoCommit;
628 =item api_query RESOURCE, CONTENT
630 Makes a GET request to RESOURCE, the name of a resource type (like
631 'customers'), with query parameters in CONTENT, unpacks the embedded search
632 results, and returns them as a list.
634 Sipwise ignores invalid query parameters rather than throwing an error, so if
635 the parameters are misspelled or make no sense for this type of query, it will
636 probably return all of the objects.
642 my ($resource, $content) = @_;
643 if ( ref $content eq 'HASH' ) {
644 $content = [ %$content ];
647 push @$content, ('rows' => 100, 'page' => 1); # 'page' is always last
648 my $result = $self->api_request('GET', $resource, $content);
651 while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) {
652 if ( ref($things) eq 'ARRAY' ) {
653 push @records, @$things;
655 push @records, $things;
657 if ( my $linknext = $result->{_links}{next} ) {
658 # unfortunately their HAL isn't entirely functional
659 # it returns "next" links that contain "page" and "rows" but no other
660 # parameters. so just count the pages:
662 $content->[-1] = $page;
664 warn "$me continued: $page\n" if $DEBUG;
665 $result = $self->api_request('GET', $resource, $content);
673 =item api_create RESOURCE, CONTENT
675 Makes a POST request to RESOURCE, the name of a resource type (like
676 'customers'), to create a new object of that type. CONTENT must be a hashref of
679 On success, will then fetch and return the newly created object. On failure,
680 will throw the "message" parameter from the request as an exception.
686 my ($resource, $content) = @_;
687 my $result = $self->api_request('POST', $resource, $content);
688 if ( $result->{location} ) {
689 return $self->api_request('GET', $result->{location});
691 die $result->{message} . "\n";
695 =item api_update ENDPOINT, CONTENT
697 Makes a PUT request to ENDPOINT, the name of a specific record (like
698 'customers/11'), to replace it with the data in CONTENT (a hashref of the
699 object's fields). On failure, will throw an exception. On success,
706 my ($endpoint, $content) = @_;
707 my $result = $self->api_request('PUT', $endpoint, $content);
708 if ( $result->{message} ) {
709 die $result->{message} . "\n";
714 =item api_delete ENDPOINT
716 Makes a DELETE request to ENDPOINT. On failure, will throw an exception.
722 my $endpoint = shift;
723 my $result = $self->api_request('DELETE', $endpoint);
724 if ( $result->{code} and $result->{code} eq '404' ) {
725 # special case: this is harmless. we tried to delete something and it
727 warn "$me api_delete $endpoint: does not exist\n";
729 } elsif ( $result->{message} ) {
730 die $result->{message} . "\n";
735 =item api_request METHOD, ENDPOINT, CONTENT
737 Makes a REST request with HTTP method METHOD, to path ENDPOINT, with content
738 CONTENT. If METHOD is GET, the content can be an arrayref or hashref to append
739 as the query argument. If it's POST or PUT, the content will be JSON-serialized
740 and sent as the request body. If it's DELETE, content will be ignored.
746 my ($method, $endpoint, $content) = @_;
747 $DEBUG ||= 1 if $self->option('debug');
749 if ($endpoint =~ /^http/) {
750 # allow directly using URLs returned from the API
753 $endpoint =~ s[/api/][]; # allow using paths returned in Location headers
754 $url = 'https://' . $self->host . '/api/' . $endpoint;
755 $url .= '/' unless $url =~ m[/$];
758 if ( lc($method) eq 'get' ) {
759 $url = URI->new($url);
760 $url->query_form($content);
762 'Accept' => 'application/json'
764 } elsif ( lc($method) eq 'post' ) {
765 $request = POST($url,
766 'Accept' => 'application/json',
767 'Content' => encode_json($content),
768 'Content-Type' => 'application/json',
770 } elsif ( lc($method) eq 'put' ) {
772 'Accept' => 'application/json',
773 'Content' => encode_json($content),
774 'Content-Type' => 'application/json',
776 } elsif ( lc($method) eq 'delete' ) {
777 $request = DELETE($url);
780 warn "$me $method $endpoint\n" if $DEBUG;
781 warn $request->as_string ."\n" if $DEBUG > 1;
782 my $response = $self->ua->request($request);
783 warn "$me received\n" . $response->as_string ."\n" if $DEBUG > 1;
785 my $decoded_response = {};
786 if ( $response->content ) {
788 $decoded_response = eval { decode_json($response->content) };
790 # then it can't be parsed; probably a low-level error of some kind.
791 warn "$me Parse error.\n".$response->content."\n\n";
792 die "$me Parse error:".$response->content . "\n";
795 if ( $response->header('Location') ) {
796 $decoded_response->{location} = $response->header('Location');
798 return $decoded_response;
801 # a little false laziness with aradial.pm
804 my $port = $self->option('port') || 1443;
805 $self->machine . ":$port";
810 $self->{_ua} ||= do {
812 if ( $self->option('ssl_no_verify') ) {
813 push @opt, ssl_opts => {
814 verify_hostname => 0,
815 SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE,
818 my $ua = LWP::UserAgent->new(@opt);
822 $self->option('username'),
823 $self->option('password')