1 package FS::part_export::ikano;
3 use vars qw(@ISA %info %loopType $me);
5 use Date::Format qw( time2str );
6 use Date::Parse qw( str2time );
7 use FS::Record qw(qsearch qsearchs dbh);
12 @ISA = qw(FS::part_export);
13 $me= '[' . __PACKAGE__ . ']';
15 tie my %options, 'Tie::IxHash',
16 'keyid' => { label=>'Ikano keyid' },
17 'username' => { label=>'Ikano username',
20 'password' => { label=>'Ikano password' },
21 'check_networks' => { label => 'Check Networks',
22 default => 'ATT,BELLCA',
24 'debug' => { label => 'Debug Mode', type => 'checkbox' },
29 'desc' => 'Provision DSL to Ikano',
30 'options' => \%options,
32 Requires installation of
33 <a href="http://search.cpan.org/dist/Net-Ikano">Net::Ikano</a> from CPAN.
37 %loopType = ( '' => 'Line-share', '0' => 'Standalone' );
39 sub rebless { shift; }
42 # we distinguish between invalid new data (return error) versus data that
43 # has legitimately changed (may eventually execute hooks; now just update)
44 # if we do add hooks later, we should work on a copy of svc_dsl and pass
45 # the old and new svc_dsl to the hooks so they know what changed
47 # current assumptions of what won't change (from their side):
48 # vendor_order_id, vendor_qual_id, vendor_order_type, pushed, monitored,
49 # last_pull, address (from qual), contact info, ProductCustomId
50 my($self, $svc_dsl) = (shift, shift);
52 my $result = $self->valid_order($svc_dsl,'pull');
53 return $result unless $result eq '';
55 $result = $self->ikano_command('ORDERSTATUS',
56 { OrderId => $svc_dsl->vendor_order_id } );
57 return $result unless ref($result); # scalar (string) is an error
59 # now we're getting an OrderResponse which should have one Order in it
60 warn "$me pull OrderResponse hash:\n".Dumper($result)
61 if $self->option('debug');
63 return 'Invalid order response' unless defined $result->{'Order'};
64 $result = $result->{'Order'};
66 return 'No order id or status returned'
67 unless defined $result->{'Status'} && defined $result->{'OrderId'};
69 local $SIG{HUP} = 'IGNORE';
70 local $SIG{INT} = 'IGNORE';
71 local $SIG{QUIT} = 'IGNORE';
72 local $SIG{TERM} = 'IGNORE';
73 local $SIG{TSTP} = 'IGNORE';
74 local $SIG{PIPE} = 'IGNORE';
76 my $oldAutoCommit = $FS::UID::AutoCommit;
77 local $FS::UID::AutoCommit = 0;
81 my $order_status = grep($_ eq $result->{'Status'}, @Net::Ikano::orderStatus)
82 ? $result->{'Status'} : '';
83 return 'Invalid new status' if $order_status eq '';
84 $svc_dsl->vendor_order_status($order_status)
85 if($svc_dsl->vendor_order_status ne $order_status);
86 $svc_dsl->monitored('')
87 if ($order_status eq 'CANCELLED' || $order_status eq 'COMPLETED');
89 # 2. fields we don't care much about
90 my %justUpdate = ( 'first' => 'FirstName',
92 'company' => 'CompanyName',
93 'username' => 'Username',
94 'password' => 'Password' );
96 while (($fsf, $ikanof) = each %justUpdate) {
97 $svc_dsl->$fsf($result->{$ikanof})
98 if $result->{$ikanof} ne $svc_dsl->$fsf;
101 # let's look inside the <Product> response element
102 my @product = $result->{'Product'};
103 return 'Invalid number of products on order' if scalar(@product) != 1;
104 my $product = $result->{'Product'}[0];
107 if($svc_dsl->loop_type eq '') { # line-share
108 # TN may change only if sub changes it and New or Change order in Completed status
109 my $tn = $product->{'PhoneNumber'};
110 if($tn ne $svc_dsl->phonenum) {
111 if( ($svc_dsl->vendor_order_type eq 'NEW'
112 || $svc_dsl->vendor_order_type eq 'CHANGE')
113 && $svc_dsl->vendor_order_status eq 'COMPLETED' ) {
114 $svc_dsl->phonenum($tn);
116 else { return 'TN has changed in an invalid state'; }
119 elsif($svc_dsl->loop_type eq '0') { # dry loop
120 # TN may change only if it's assigned while a New or Change order is in progress
121 return 'Invalid PhoneNumber value for a dry loop'
122 if $product->{'PhoneNumber'} ne 'STANDALONE';
123 my $tn = $product->{'VirtualPhoneNumber'};
124 if($tn ne $svc_dsl->phonenum) {
125 if( ($svc_dsl->vendor_order_type eq 'NEW'
126 || $svc_dsl->vendor_order_type eq 'CHANGE')
127 && $svc_dsl->vendor_order_status ne 'COMPLETED'
128 && $svc_dsl->vendor_order_status ne 'CANCELLED') {
129 $svc_dsl->phonenum($tn);
131 else { return 'TN has changed in an invalid state'; }
135 # 4. desired_due_date - may change if manually changed
136 if($svc_dsl->vendor_order_type eq 'NEW'
137 || $svc_dsl->vendor_order_type eq 'CHANGE'){
138 my $f = str2time($product->{'DateToOrder'});
139 return 'Invalid DateToOrder' unless $f;
140 $svc_dsl->desired_due_date($f) if $svc_dsl->desired_due_date ne $f;
141 # XXX: optionally sync back to start_date or whatever...
143 elsif($svc_dsl->vendor_order_type eq 'CANCEL'){
144 my $f = str2time($product->{'DateToDisconnect'});
145 return 'Invalid DateToDisconnect' unless $f;
146 $svc_dsl->desired_due_date($f) if $svc_dsl->desired_due_date ne $f;
147 # XXX: optionally sync back to expire or whatever...
151 if($svc_dsl->vendor_order_type eq 'NEW'
152 || $svc_dsl->vendor_order_type eq 'CHANGE') {
153 my $f = str2time($product->{'ActivationDate'});
154 if($svc_dsl->vendor_order_status ne 'NEW'
155 && $svc_dsl->vendor_order_status ne 'CANCELLED') {
156 return 'Invalid ActivationDate' unless $f;
157 $svc_dsl->due_date($f) if $svc_dsl->due_date ne $f;
160 # Ikano API does not implement the proper disconnect date,
161 # so we can't do anything about it
163 # 6. staticips - for now just comma-separate them
164 my $tstatics = $result->{'StaticIps'};
165 my @istatics = defined $tstatics ? @$tstatics : ();
166 my $ostatics = $svc_dsl->staticips;
167 my @ostatics = split(',',$ostatics);
168 # more horrible search/sync code below...
169 my $staticsChanged = 0;
170 foreach $istatic ( @istatics ) { # they have, we don't
171 unless ( grep($_ eq $istatic, @ostatics) ) {
172 push @ostatics, $istatic;
176 for(my $i=0; $i < scalar(@ostatics); $i++) {
177 unless ( grep($_ eq $ostatics[$i], @istatics) ) {
178 splice(@ostatics,$i,1);
183 $svc_dsl->staticips(join(',',@ostatics)) if $staticsChanged;
185 # 7. notes - put them into the common format and compare
186 my $tnotes = $result->{'OrderNotes'};
187 my @tnotes = defined $tnotes ? @$tnotes : ();
188 my @inotes = (); # all Ikano OrderNotes as FS::dsl_note objects
189 my $notesChanged = 0;
190 foreach $tnote ( @tnotes ) {
191 my $inote = $self->ikano2fsnote($tnote,$svc_dsl->svcnum);
192 return 'Cannot parse note' unless ref($inote);
193 push @inotes, $inote;
195 my @onotes = $svc_dsl->notes;
196 # assume notes we already have don't change & no notes added from our side
197 # so using the horrible code below just find what we're missing and add it
199 foreach $inote ( @inotes ) {
201 foreach $onote ( @onotes ) {
202 if($onote->date == $inote->date && $onote->note eq $inote->note) {
207 $error = $inote->insert unless ( $found );
209 $dbh->rollback if $oldAutoCommit;
210 return "Cannot add note: $error";
214 $svc_dsl->last_pull((time));
215 local $FS::svc_Common::noexport_hack = 1;
216 $error = $svc_dsl->replace;
218 $dbh->rollback if $oldAutoCommit;
219 return "Cannot update DSL data: $error";
222 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
228 my($self,$n,$svcnum) = (shift,shift,shift);
229 my @ikanoRequired = qw( HighPriority StaffId Date Text CompanyStaffId );
230 return '' unless defined $n->{'HighPriority'}
231 && defined $n->{'StaffId'}
232 && defined $n->{'CompanyStaffId'}
233 && defined $n->{'Date'}
234 && defined $n->{'Text'}
237 $by = "Ikano" if $n->{'CompanyStaffId'} == -1 && $n->{'StaffId'} != -1;
238 $by = "Us" if $n->{'StaffId'} == -1 && $n->{'CompanyStaffId'} != -1;
240 $fsnote = new FS::dsl_note( {
243 'priority' => $n->{'HighPriority'} eq 'false' ? 'N' : 'H',
244 'date' => int(str2time($n->{'Date'})),
245 'note' => $n->{'Text'},
254 my($self,$svc_dsl) = (shift,shift);
255 my $conf = new FS::Conf;
256 my $date_format = $conf->config('date_format') || '%m/%d/%Y';
257 my @notes = $svc_dsl->notes;
258 my $html = '<TABLE border="1" cellspacing="2" cellpadding="2" id="dsl_notes">
259 <TR><TH>Date</TH><TH>By</TH><TH>Priority</TH><TH>Note</TH></TR>';
260 foreach $note ( @notes ) {
262 <TD>".time2str("$date_format %H:%M",$note->date)."</TD>
263 <TD>".$note->by."</TD>
264 <TD>". ($note->priority eq 'N' ? 'Normal' : 'High') ."</TD>
265 <TD>".$note->note."</TD></TR>";
271 sub loop_type_long { # sub, not a method
272 my($svc_dsl) = (shift);
273 return $loopType{$svc_dsl->loop_type};
277 my( $self, $command, $args ) = @_;
281 my $ikano = Net::Ikano->new(
282 'keyid' => $self->option('keyid'),
283 'username' => $self->option('username'),
284 'password' => $self->option('password'),
285 'debug' => $self->option('debug'),
288 $ikano->$command($args);
292 eval "use Net::Ikano;";
297 my( $self, $svc_dsl, $action ) = (shift, shift, shift);
301 warn "$me valid_order action=$action svc_dsl:\n". Dumper($svc_dsl)
302 if $self->option('debug');
304 # common to all order types/status/loop_type
305 my $error = !($svc_dsl->desired_due_date
306 && grep($_ eq $svc_dsl->vendor_order_type, @Net::Ikano::orderType)
309 && defined $svc_dsl->loop_type
310 && $svc_dsl->vendor_qual_id
312 return 'Missing or invalid order data' if $error;
314 return 'Package does not have an external id configured'
315 if $svc_dsl->cust_svc->cust_pkg->part_pkg->options('externalid',1) eq '';
317 return 'No valid qualification for this order'
318 unless qsearch( 'qual', { 'vendor_qual_id' => $svc_dsl->vendor_qual_id });
320 # now go by order type
321 # weird ifs & long lines for readability and ease of understanding - don't change
322 if($svc_dsl->vendor_order_type eq 'NEW') {
323 if($svc_dsl->pushed) {
324 $error = !( ($action eq 'pull' || $action eq 'statuschg'
325 || $action eq 'delete')
326 && length($svc_dsl->vendor_order_id) > 0
327 && length($svc_dsl->vendor_order_status) > 0
329 return 'Invalid order data' if $error;
331 return 'Phone number required for status change'
332 if ($action eq 'statuschg' && length($svc_dsl->phonenum) < 1);
334 else { # unpushed New order - cannot do anything other than push it
335 $error = !($action eq 'insert'
336 && length($svc_dsl->vendor_order_id) < 1
337 && length($svc_dsl->vendor_order_status) < 1
338 && ( ($svc_dsl->phonenum eq '' && $svc_dsl->loop_type eq '0') # dry
339 || ($svc_dsl->phonenum ne '' && $svc_dsl->loop_type eq '') # line-share
342 return 'Invalid order data' if $error;
345 elsif($svc_dsl->vendor_order_type eq 'CANCEL') {
347 elsif($svc_dsl->vendor_order_type eq 'CHANGE') {
354 my ($self,$vendor_qual_id,$ProductCustomId) = (shift,shift,shift);
355 my $qual = qsearchs( 'qual', { 'vendor_qual_id' => $vendor_qual_id });
356 return '' unless $qual;
357 my %qual_options = $qual->options;
358 while (($optionname, $optionvalue) = each %qual_options) {
359 if ( $optionname =~ /^ikano_Network_(\d+)_ProductGroup_(\d+)_Product_(\d+)_ProductCustomId$/
360 && $optionvalue eq $ProductCustomId ) {
362 my $productgroup = $2;
363 return $qual->option("ikano_Network_".$network."_ProductGroup_".$productgroup."_TermsId");
370 my( $self, $svc_dsl ) = (shift, shift);
374 my $result = $self->valid_order($svc_dsl,'insert');
375 return $result unless $result eq '';
377 my $isp_chg = $svc_dsl->isp_chg eq 'Y' ? 'YES' : 'NO';
378 my $contactTN = $svc_dsl->cust_svc->cust_pkg->cust_main->daytime;
379 $contactTN =~ s/[^0-9]//g;
381 my $ProductCustomId = $svc_dsl->cust_svc->cust_pkg->part_pkg->option('externalid',1);
385 ProductCustomId => $ProductCustomId,
386 TermsId => $self->qual2termsid($svc_dsl->vendor_qual_id,$ProductCustomId),
387 DSLPhoneNumber => $svc_dsl->loop_type eq '0' ? 'STANDALONE'
388 : $svc_dsl->phonenum,
389 Password => $svc_dsl->password,
390 PrequalId => $svc_dsl->vendor_qual_id,
391 CompanyName => $svc_dsl->company,
392 FirstName => $svc_dsl->first,
393 LastName => $svc_dsl->last,
395 ContactMethod => 'PHONE',
396 ContactPhoneNumber => $contactTN,
397 ContactEmail => 'x@x.xx',
399 DateToOrder => time2str("%Y-%m-%d",$svc_dsl->desired_due_date),
400 RequestClientIP => '127.0.0.1',
401 IspChange => $isp_chg,
402 IspPrevious => $isp_chg eq 'YES' ? $svc_dsl->isp_prev : '',
403 CurrentProvider => $isp_chg eq 'NO' ? $svc_dsl->isp_prev : '',
406 $result = $self->ikano_command('ORDER',$args);
407 return $result unless ref($result); # scalar (string) is an error
409 # now we're getting an OrderResponse which should have one Order in it
410 warn "$me _export_insert OrderResponse hash:\n".Dumper($result)
411 if $self->option('debug');
413 return 'Invalid order response' unless defined $result->{'Order'};
414 $result = $result->{'Order'};
416 return 'No/invalid order id or status returned'
417 unless defined $result->{'Status'} && defined $result->{'OrderId'}
418 && grep($_ eq $result->{'Status'}, @Net::Ikano::orderStatus);
420 $svc_dsl->pushed(time);
421 $svc_dsl->last_pull((time)+1);
422 $svc_dsl->vendor_order_id($result->{'OrderId'});
423 $svc_dsl->vendor_order_status($result->{'Status'});
424 $svc_dsl->username($result->{'Username'});
425 local $FS::svc_Common::noexport_hack = 1;
426 local $FS::UID::AutoCommit = 0;
427 $result = $svc_dsl->replace;
428 return 'Error setting DSL fields' if $result;
432 sub _export_replace {
433 my( $self, $new, $old ) = (shift, shift, shift);
434 # XXX only supports password changes now, but should return error if
435 # another change is attempted?
437 if($new->password ne $old->password) {
438 my $result = $self->valid_order($new,'statuschg');
439 return $result unless $result eq '';
441 $result = $self->ikano_command('PASSWORDCHANGE',
442 { DSLPhoneNumber => $new->phonenum,
443 NewPassword => $new->password,
445 return $result unless ref($result); # scalar (string) is an error
447 return 'Error changing password' unless defined $result->{'Password'}
448 && $result->{'Password'} eq $new->password;
455 my( $self, $svc_dsl ) = (shift, shift);
457 my $result = $self->valid_order($svc_dsl,'delete');
458 return $result unless $result eq '';
460 # for now allow an immediate cancel only on New orders in New/Pending status
461 #XXX: add support for Chance and Cancel orders in New/Pending status later
463 if($svc_dsl->vendor_order_type eq 'NEW') {
464 if($svc_dsl->vendor_order_status eq 'NEW'
465 || $svc_dsl->vendor_order_status eq 'PENDING') {
466 my $result = $self->ikano_command('CANCEL',
467 { OrderId => $svc_dsl->vendor_order_id, } );
468 return $result unless ref($result); # scalar (string) is an error
470 return $self->dsl_pull($svc_dsl);
473 return "Cannot cancel a NEW order unless it's in NEW or PENDING status";
477 return 'Canceling orders other than NEW orders is not currently implemented';
484 my( $self, $svc_dsl, $type ) = (shift, shift, shift);
486 my $result = $self->valid_order($svc_dsl,'statuschg');
487 return $result unless $result eq '';
489 # get the DSLServiceId
490 $result = $self->ikano_command('CUSTOMERLOOKUP',
491 { PhoneNumber => $svc_dsl->phonenum } );
492 return $result unless ref($result); # scalar (string) is an error
493 return 'No DSLServiceId found' unless defined $result->{'DSLServiceId'};
494 my $DSLServiceId = $result->{'DSLServiceId'};
496 $result = $self->ikano_command('ACCOUNTSTATUSCHANGE',
497 { DSLPhoneNumber => $svc_dsl->phonenum,
498 DSLServiceId => $DSLServiceId,
501 return $result unless ref($result); # scalar (string) is an error
505 sub _export_suspend {
506 my( $self, $svc_dsl ) = (shift, shift);
507 $self->statuschg($svc_dsl,"SUSPEND");
510 sub _export_unsuspend {
511 my( $self, $svc_dsl ) = (shift, shift);
512 $self->statuschg($svc_dsl,"UNSUSPEND");