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 handle',
29 'subscriber_profile_set' => {
30 label => 'Subscriber profile set name',
32 'reseller_id' => { label => 'Reseller ID' },
33 'ssl_no_verify' => { label => 'Skip SSL certificate validation',
38 tie my %roles, 'Tie::IxHash',
39 'subscriber' => { label => 'Subscriber',
43 'did' => { label => 'DID',
50 'svc' => [qw( svc_acct svc_phone )],
51 'desc' => 'Provision to a Sipwise sip:provider server',
52 'options' => \%options,
55 <P>Export to a <b>sip:provider</b> server.</P>
56 <P>This requires two service definitions to be configured on the same package:
58 <LI>An account service for a SIP client account ("subscriber"). The
59 <i>username</i> will be the SIP username. The <i>domsvc</i> should point
60 to a domain service to use as the SIP domain name.</LI>
61 <LI>A phone service for a DID. The <i>phonenum</i> here will be a PSTN
62 number. The <i>forward_svcnum</i> field should be set to the account that
63 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
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) = @_;
299 if ( $old and $new->forward_svcnum ne $old->forward_svcnum ) {
300 my $old_svc_acct = $self->acct_for_did($old);
301 $self->replace_subscriber( $old_svc_acct ) if $old_svc_acct;
303 my $new_svc_acct = $self->acct_for_did($new);
304 $self->replace_subscriber( $new_svc_acct ) if $new_svc_acct;
311 =item get_subscriber SVC
313 Gets the subscriber record for SVC, if there is one.
321 my $svcnum = $svc->svcnum;
322 my $svcid = "svc#$svcnum";
324 my $pkgnum = $svc->cust_svc->pkgnum;
325 my $custid = "cust_pkg#$pkgnum";
327 my @subscribers = grep { $_->{external_id} eq $svcid }
328 $self->api_query('subscribers',
329 [ 'customer_external_id' => $custid ]
331 warn "$me multiple subscribers for external_id $svcid.\n"
332 if scalar(@subscribers) > 1;
337 # internal method: find DIDs that forward to this service
339 sub did_numbers_for_svc {
344 'table' => 'svc_phone',
345 'hashref' => { 'forward_svcnum' => $svc->svcnum }
347 foreach my $did (@dids) {
348 # only include them if they're interesting to this export
349 if ( $self->svc_role($did) eq 'did' ) {
351 if ($did->countrycode) {
352 $phonenum = Number::Phone->new('+' . $did->countrycode . $did->phonenum);
355 my $country = $did->cust_svc->cust_pkg->cust_location->country;
356 $phonenum = Number::Phone->new($country, $did->phonenum);
359 die "Can't process phonenum ".$did->countrycode . $did->phonenum . "\n";
362 { 'cc' => $phonenum->country_code,
363 'ac' => $phonenum->areacode,
364 'sn' => $phonenum->subscriber
371 sub get_subscriber_profile_set_id {
373 if ( my $setname = $self->option('subscriber_profile_set') ) {
374 my ($set) = $self->api_query('subscriberprofilesets',
377 die "Subscriber profile set '$setname' not found" unless $set;
383 sub insert_subscriber {
387 my $cust = $self->find_or_create_customer($svc);
388 my $svcid = "svc#" . $svc->svcnum;
389 my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
390 $status = 'active' if $svc->get('unsuspended');
391 my $domain = $self->find_or_create_domain($svc->domain);
393 my @numbers = $self->did_numbers_for_svc($svc);
394 my $first_number = shift @numbers;
396 my $profile_set_id = $self->get_subscriber_profile_set_id;
397 my $subscriber = $self->api_create('subscribers',
399 'alias_numbers' => \@numbers,
400 'customer_id' => $cust->{id},
401 'display_name' => $svc->finger,
402 'domain_id' => $domain->{id},
403 'external_id' => $svcid,
404 'password' => $svc->_password,
405 'primary_number' => $first_number,
406 'profile_set_id' => $profile_set_id,
408 'username' => $svc->username,
413 sub replace_subscriber {
416 my $old = shift || $svc->replace_old;
417 my $svcid = "svc#" . $svc->svcnum;
419 my $cust = $self->find_or_create_customer($svc);
420 my $status = $svc->cust_svc->cust_pkg->susp ? 'locked' : 'active';
421 $status = 'active' if $svc->get('unsuspended');
422 my $domain = $self->find_or_create_domain($svc->domain);
424 my @numbers = $self->did_numbers_for_svc($svc);
425 my $first_number = shift @numbers;
427 my $subscriber = $self->get_subscriber($svc);
430 my $id = $subscriber->{id};
431 if ( $svc->username ne $old->username ) {
432 # have to delete and recreate
433 $self->api_delete("subscribers/$id");
434 $self->insert_subscriber($svc);
436 my $profile_set_id = $self->get_subscriber_profile_set_id;
437 $self->api_update("subscribers/$id",
439 'alias_numbers' => \@numbers,
440 'customer_id' => $cust->{id},
441 'display_name' => $svc->finger,
442 'domain_id' => $domain->{id},
443 'email' => $svc->email,
444 'external_id' => $svcid,
445 'password' => $svc->_password,
446 'primary_number' => $first_number,
447 'profile_set_id' => $profile_set_id,
449 'username' => $svc->username,
454 warn "$me subscriber not found for $svcid; creating new\n";
455 $self->insert_subscriber($svc);
459 sub delete_subscriber {
462 my $svcid = "svc#" . $svc->svcnum;
463 my $pkgnum = $svc->cust_svc->pkgnum;
464 my $custid = "cust_pkg#$pkgnum";
466 my $subscriber = $self->get_subscriber($svc);
469 my $id = $subscriber->{id};
470 $self->api_delete("subscribers/$id");
472 warn "$me subscriber not found for $svcid (would be deleted)\n";
475 my (@other_subs) = $self->api_query('subscribers',
476 [ 'customer_external_id' => $custid ]
479 # then it's safe to remove the customer
480 my ($cust) = $self->api_query('customers', [ 'external_id' => $custid ]);
482 warn "$me customer not found for $custid\n";
485 my $id = $cust->{id};
486 my $contact_id = $cust->{contact_id};
487 if ( $cust->{'status'} ne 'terminated' ) {
488 # can't delete customers, have to cancel them
489 $cust->{'status'} = 'terminated';
490 $cust->{'external_id'} = ""; # dissociate it from this pkgnum
491 $cust->{'contact_id'} = 1; # set to the system default contact
492 $self->api_update("customers/$id", $cust);
494 # can and should delete contacts though
495 $self->api_delete("customercontacts/$contact_id");
503 =item import_cdrs START, END
505 Retrieves CDRs for calls in the date range from START to END and inserts them
506 as a CDR batch. On success, returns a new cdr_batch object. On failure,
507 returns an error message. If there are no new CDRs, returns nothing.
512 my ($self, $start, $end) = @_;
516 my $oldAutoCommit = $FS::UID::AutoCommit;
517 local $FS::UID::AutoCommit = 0;
519 ($start, $end) = ($end, $start) if $end < $start;
520 $start = DateTime->from_epoch(epoch => $start, time_zone => 'local');
521 $end = DateTime->from_epoch(epoch => $end, time_zone => 'local');
522 $end->subtract(seconds => 1); # filter by >= and <= only, so...
524 # a little different from the usual: we have to fetch these subscriber by
525 # subscriber, not all at once.
527 'table' => 'svc_acct',
528 'addl_from' => ' JOIN cust_svc USING (svcnum)' .
529 ' JOIN export_svc USING (svcpart)',
530 'extra_sql' => ' WHERE export_svc.role = \'subscriber\''.
531 ' AND export_svc.exportnum = '.$self->exportnum
534 my @args = ( 'start_ge' => $start->iso8601,
535 'start_le' => $end->iso8601,
539 SVC: foreach my $svc (@svcs) {
540 my $subscriber = $self->get_subscriber($svc);
542 warn "$me user ".$svc->label." is not configured on the SIP server.\n";
545 my $id = $subscriber->{id};
548 # alias_field tells "calllists" which field from the source and
549 # destination to use as the "own_cli" and "other_cli" of the call.
550 # "user" = username@domain.
551 @calls = $self->api_query('calllists', [
552 'subscriber_id' => $id,
553 'alias_field' => 'user',
557 $error = "$me $_ (retrieving records for ".$svc->label.")";
560 if (@calls and !$cdr_batch) {
561 # create a cdr_batch if needed
562 my $cdrbatchname = 'sipwise-' . $self->exportnum . '-' . $end->epoch;
563 $cdr_batch = FS::cdr_batch->new({ cdrbatch => $cdrbatchname });
564 $error = $cdr_batch->insert;
569 foreach my $c (@calls) {
570 # avoid double-importing
571 my $uniqueid = $c->{call_id};
572 if ( FS::cdr->row_exists("uniqueid = ?", $uniqueid) ) {
573 warn "skipped call with uniqueid = '$uniqueid' (already imported)\n"
577 my $src = $c->{own_cli};
578 my $dst = $c->{other_cli};
579 if ( $c->{direction} eq 'in' ) { # then reverse them
580 ($src, $dst) = ($dst, $src);
582 # parse duration from H:MM:SS format
584 if ( $c->{duration} =~ /^(\d+):(\d+):(\d+)$/ ) {
585 $duration = $3 + (60 * $2) + (3600 * $1);
587 $error = "call $uniqueid: unparseable duration '".$c->{duration}."'";
590 # use the username@domain label for src and/or dst if possible
591 my $cdr = FS::cdr->new({
592 uniqueid => $uniqueid,
593 upstream_price => $c->{customer_cost},
594 startdate => parse_datetime($c->{start_time}),
595 disposition => $c->{status},
596 duration => $duration,
597 billsec => $duration,
601 $error ||= $cdr->insert;
607 dbh->rollback if $oldAutoCommit;
609 } elsif ( $cdr_batch ) {
610 dbh->commit if $oldAutoCommit;
621 =item api_query RESOURCE, CONTENT
623 Makes a GET request to RESOURCE, the name of a resource type (like
624 'customers'), with query parameters in CONTENT, unpacks the embedded search
625 results, and returns them as a list.
627 Sipwise ignores invalid query parameters rather than throwing an error, so if
628 the parameters are misspelled or make no sense for this type of query, it will
629 probably return all of the objects.
635 my ($resource, $content) = @_;
636 if ( ref $content eq 'HASH' ) {
637 $content = [ %$content ];
640 push @$content, ('rows' => 100, 'page' => 1); # 'page' is always last
641 my $result = $self->api_request('GET', $resource, $content);
644 while ( my $things = $result->{_embedded}{"ngcp:$resource"} ) {
645 if ( ref($things) eq 'ARRAY' ) {
646 push @records, @$things;
648 push @records, $things;
650 if ( my $linknext = $result->{_links}{next} ) {
651 # unfortunately their HAL isn't entirely functional
652 # it returns "next" links that contain "page" and "rows" but no other
653 # parameters. so just count the pages:
655 $content->[-1] = $page;
657 warn "$me continued: $page\n" if $DEBUG;
658 $result = $self->api_request('GET', $resource, $content);
666 =item api_create RESOURCE, CONTENT
668 Makes a POST request to RESOURCE, the name of a resource type (like
669 'customers'), to create a new object of that type. CONTENT must be a hashref of
672 On success, will then fetch and return the newly created object. On failure,
673 will throw the "message" parameter from the request as an exception.
679 my ($resource, $content) = @_;
680 my $result = $self->api_request('POST', $resource, $content);
681 if ( $result->{location} ) {
682 return $self->api_request('GET', $result->{location});
684 die $result->{message} . "\n";
688 =item api_update ENDPOINT, CONTENT
690 Makes a PUT request to ENDPOINT, the name of a specific record (like
691 'customers/11'), to replace it with the data in CONTENT (a hashref of the
692 object's fields). On failure, will throw an exception. On success,
699 my ($endpoint, $content) = @_;
700 my $result = $self->api_request('PUT', $endpoint, $content);
701 if ( $result->{message} ) {
702 die $result->{message} . "\n";
707 =item api_delete ENDPOINT
709 Makes a DELETE request to ENDPOINT. On failure, will throw an exception.
715 my $endpoint = shift;
716 my $result = $self->api_request('DELETE', $endpoint);
717 if ( $result->{code} and $result->{code} eq '404' ) {
718 # special case: this is harmless. we tried to delete something and it
720 warn "$me api_delete $endpoint: does not exist\n";
722 } elsif ( $result->{message} ) {
723 die $result->{message} . "\n";
728 =item api_request METHOD, ENDPOINT, CONTENT
730 Makes a REST request with HTTP method METHOD, to path ENDPOINT, with content
731 CONTENT. If METHOD is GET, the content can be an arrayref or hashref to append
732 as the query argument. If it's POST or PUT, the content will be JSON-serialized
733 and sent as the request body. If it's DELETE, content will be ignored.
739 my ($method, $endpoint, $content) = @_;
740 $DEBUG ||= 1 if $self->option('debug');
742 if ($endpoint =~ /^http/) {
743 # allow directly using URLs returned from the API
746 $endpoint =~ s[/api/][]; # allow using paths returned in Location headers
747 $url = 'https://' . $self->host . '/api/' . $endpoint;
748 $url .= '/' unless $url =~ m[/$];
751 if ( lc($method) eq 'get' ) {
752 $url = URI->new($url);
753 $url->query_form($content);
755 'Accept' => 'application/json'
757 } elsif ( lc($method) eq 'post' ) {
758 $request = POST($url,
759 'Accept' => 'application/json',
760 'Content' => encode_json($content),
761 'Content-Type' => 'application/json',
763 } elsif ( lc($method) eq 'put' ) {
765 'Accept' => 'application/json',
766 'Content' => encode_json($content),
767 'Content-Type' => 'application/json',
769 } elsif ( lc($method) eq 'delete' ) {
770 $request = DELETE($url);
773 warn "$me $method $endpoint\n" if $DEBUG;
774 warn $request->as_string ."\n" if $DEBUG > 1;
775 my $response = $self->ua->request($request);
776 warn "$me received\n" . $response->as_string ."\n" if $DEBUG > 1;
778 my $decoded_response = {};
779 if ( $response->content ) {
781 $decoded_response = eval { decode_json($response->content) };
783 # then it can't be parsed; probably a low-level error of some kind.
784 warn "$me Parse error.\n".$response->content."\n\n";
785 die "$me Parse error:".$response->content . "\n";
788 if ( $response->header('Location') ) {
789 $decoded_response->{location} = $response->header('Location');
791 return $decoded_response;
794 # a little false laziness with aradial.pm
797 my $port = $self->option('port') || 1443;
798 $self->machine . ":$port";
803 $self->{_ua} ||= do {
805 if ( $self->option('ssl_no_verify') ) {
806 push @opt, ssl_opts => { verify_hostname => 0 };
808 my $ua = LWP::UserAgent->new(@opt);
812 $self->option('username'),
813 $self->option('password')