RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / part_export / ikano.pm
1 package FS::part_export::ikano;
2
3 use strict;
4 use warnings;
5 use vars qw(@ISA %info %loopType $me);
6 use Tie::IxHash;
7 use Date::Format qw( time2str );
8 use Date::Parse qw( str2time );
9 use FS::Record qw(qsearch qsearchs dbh);
10 use FS::part_export;
11 use FS::svc_dsl;
12 use Data::Dumper;
13 use Carp qw(carp);
14
15 @ISA = qw(FS::part_export);
16 $me= '[' .  __PACKAGE__ . ']';
17
18 tie my %options, 'Tie::IxHash',
19   'keyid'         => { label=>'Ikano keyid' },
20   'username'      => { label=>'Ikano username',
21                         default => 'admin',
22                         },
23   'password'      => { label=>'Ikano password' },
24   'check_networks' => { label => 'Check Networks',
25                     default => 'ATT,BELLCA',
26                     },
27   'debug' => { label => 'Debug Mode',  type => 'checkbox' },
28   'import' => { label => 'Import Mode',  type => 'checkbox' },
29 ;
30
31 %info = (
32   'svc'     => 'svc_dsl',
33   'desc'    => 'Provision DSL to Ikano',
34   'options' => \%options,
35   'no_machine' => 1,
36   'notes'   => <<'END'
37 Requires installation of
38 <a href="http://search.cpan.org/dist/Net-Ikano">Net::Ikano</a> from CPAN.
39 END
40 );
41     
42 %loopType = ( '' => 'Line-share', '0' => 'Standalone' );
43
44 sub rebless { shift; }
45
46 sub external_pkg_map { 1; }
47
48 sub location_types {
49   (
50     ''     => '(None)',
51     'APT'  => 'Apartment',
52     'BLDG' => 'Building',
53     'FLR'  => 'Floor',
54     'LOT'  => 'Lot',
55     'RM'   => 'Room',
56     'SLIP' => 'Slip',
57     'SUIT' => 'Suite',
58     'TRLR' => 'Trailer',
59     'UNIT' => 'Unit',
60     'WING' => 'Wing',
61   );
62 }
63
64 sub location_types_parse {
65   my $class = shift;
66   my %t = $class->location_types;
67   delete $t{''};
68   (
69     (map { $_ => $_ } keys %t),
70     (reverse %t),
71     'STE' => 'SUIT', #USPS
72   );
73 }
74
75 sub import_mode {
76     my $self = shift;
77     $self->option('import');
78 }
79
80 sub dsl_pull {
81 # we distinguish between invalid new data (return error) versus data that
82 # has legitimately changed (may eventually execute hooks; now just update)
83 # if we do add hooks later, we should work on a copy of svc_dsl and pass
84 # the old and new svc_dsl to the hooks so they know what changed
85 #
86 # current assumptions of what won't change (from their side):
87 # vendor_order_id, vendor_qual_id, vendor_order_type, pushed, monitored,
88 # last_pull, address (from qual), contact info, ProductCustomId
89     my($self, $svc_dsl, $threshold, $import) = (shift, shift, shift, shift);
90
91     return 'Invalid operation - Import Mode is enabled' 
92         if $self->import_mode && !$import;
93
94     return 'invalid arguments' if $import && !$self->import_mode;
95
96     warn "$me dsl_pull: import mode" if $self->option('debug');
97
98     unless ( $import ) { 
99         my $result = $self->valid_order($svc_dsl,'pull');
100         return $result unless $result eq '';
101     }
102
103     my $now = time;
104     if($now - $svc_dsl->last_pull < $threshold) {
105         warn "$me skipping pull since threshold not reached (svcnum="
106             . $svc_dsl->svcnum . ",now=$now,threshold=$threshold,last_pull="
107             . $svc_dsl->last_pull .")" if $self->option('debug');
108         return '';
109     }
110   
111     my $result = $self->ikano_command('ORDERSTATUS', 
112                                     { OrderId => $svc_dsl->vendor_order_id } );
113     return $result unless ref($result); # scalar (string) is an error
114
115     # now we're getting an OrderResponse which should have one Order in it
116     warn "$me pull OrderResponse hash:\n".Dumper($result) 
117         if $self->option('debug');
118   
119     return 'Invalid order response' unless defined $result->{'Order'};
120     $result = $result->{'Order'};
121
122     return 'No order id or status returned' 
123         unless defined $result->{'Status'} && defined $result->{'OrderId'};
124         
125     local $SIG{HUP} = 'IGNORE';
126     local $SIG{INT} = 'IGNORE';
127     local $SIG{QUIT} = 'IGNORE';
128     local $SIG{TERM} = 'IGNORE';
129     local $SIG{TSTP} = 'IGNORE';
130     local $SIG{PIPE} = 'IGNORE';
131
132     my $oldAutoCommit = $FS::UID::AutoCommit;
133     local $FS::UID::AutoCommit = 0;
134     my $dbh = dbh;
135
136     # 1. status 
137     my $order_status = grep($_ eq $result->{'Status'}, @Net::Ikano::orderStatus)
138                             ? $result->{'Status'} : '';
139     return 'Invalid new status' if $order_status eq '';
140     $svc_dsl->vendor_order_status($order_status) 
141         if($svc_dsl->vendor_order_status ne $order_status);
142     $svc_dsl->monitored('') 
143             if ($order_status eq 'CANCELLED' || $order_status eq 'COMPLETED');
144
145     # 2. fields we don't care much about
146     my %justUpdate = ( 'first' => 'FirstName',
147                     'last' => 'LastName',
148                     'company' => 'CompanyName',
149                     'username' => 'Username',
150                     'password' => 'Password' );
151
152     my($fsf, $ikanof);
153     while (($fsf, $ikanof) = each %justUpdate) {
154        $svc_dsl->$fsf($result->{$ikanof}) 
155             if $result->{$ikanof} ne $svc_dsl->$fsf;
156     }
157
158     # let's look inside the <Product> response element
159     my @product = $result->{'Product'}; 
160     return 'Invalid number of products on order' if scalar(@product) != 1;
161     my $product = $result->{'Product'}[0];
162
163     $svc_dsl->vendor_order_type('NEW') if $import; # lame
164
165     # 3. phonenum 
166     if ( $import ) {
167         if ( $product->{'PhoneNumber'} eq 'STANDALONE' ) {
168             $svc_dsl->phonenum($product->{'VirtualPhoneNumber'});
169             $svc_dsl->loop_type('0');
170         }
171         else {
172             $svc_dsl->phonenum($product->{'PhoneNumber'});
173             $svc_dsl->loop_type('');
174         }
175     }
176     elsif($svc_dsl->loop_type eq '') { # line-share
177 # TN may change only if sub changes it and New or Change order in Completed status
178         my $tn = $product->{'PhoneNumber'};
179         if($tn ne $svc_dsl->phonenum) {
180             if( ($svc_dsl->vendor_order_type eq 'NEW' 
181                 || $svc_dsl->vendor_order_type eq 'CHANGE')
182                && $svc_dsl->vendor_order_status eq 'COMPLETED' ) {
183                 $svc_dsl->phonenum($tn);
184             }
185             else { return 'TN has changed in an invalid state'; }
186         }
187     }
188     elsif($svc_dsl->loop_type eq '0') { # dry loop
189 # TN may change only if it's assigned while a New or Change order is in progress
190         return 'Invalid PhoneNumber value for a dry loop' 
191             if $product->{'PhoneNumber'} ne 'STANDALONE';
192         my $tn = $product->{'VirtualPhoneNumber'};
193         if($tn ne $svc_dsl->phonenum) {
194             if( ($svc_dsl->vendor_order_type eq 'NEW' 
195                 || $svc_dsl->vendor_order_type eq 'CHANGE')
196               && $svc_dsl->vendor_order_status ne 'COMPLETED'
197               && $svc_dsl->vendor_order_status ne 'CANCELLED') {
198                 $svc_dsl->phonenum($tn);
199             }
200             else { return 'TN has changed in an invalid state'; }
201         }
202     }
203     
204     # 4. desired_due_date - may change if manually changed
205     if($svc_dsl->vendor_order_type eq 'NEW' 
206             || $svc_dsl->vendor_order_type eq 'CHANGE'){
207         my $f = str2time($product->{'DateToOrder'});
208         return 'Invalid DateToOrder' unless $f;
209         $svc_dsl->desired_due_date($f) if $svc_dsl->desired_due_date ne $f;
210         # XXX: optionally sync back to start_date or whatever... 
211     }
212     elsif($svc_dsl->vendor_order_type eq 'CANCEL'){
213         my $f = str2time($product->{'DateToDisconnect'});
214         return 'Invalid DateToDisconnect' unless $f;
215         $svc_dsl->desired_due_date($f) if $svc_dsl->desired_due_date ne $f;
216         # XXX: optionally sync back to expire or whatever... 
217     }
218
219     # 5. due_date
220     if($svc_dsl->vendor_order_type eq 'NEW' 
221           || $svc_dsl->vendor_order_type eq 'CHANGE') {
222         my $f = str2time($product->{'ActivationDate'});
223         if($svc_dsl->vendor_order_status ne 'NEW'
224             && $svc_dsl->vendor_order_status ne 'CANCELLED') {
225             return 'Invalid ActivationDate' unless $f;
226             $svc_dsl->due_date($f) if $svc_dsl->due_date ne $f;
227         }
228     }
229     # Ikano API does not implement the proper disconnect date,
230     # so we can't do anything about it
231
232     # 6. staticips - for now just comma-separate them
233     my $tstatics = $result->{'StaticIps'};
234     my @istatics = defined $tstatics ? @$tstatics : ();
235     my $ostatics = $svc_dsl->staticips;
236     my @ostatics = split(',',$ostatics);
237     # more horrible search/sync code below...
238     my $staticsChanged = 0;
239     foreach my $istatic ( @istatics ) { # they have, we don't
240         unless ( grep($_ eq $istatic, @ostatics) ) {
241             push @ostatics, $istatic;
242             $staticsChanged = 1;
243         }
244     }
245     for(my $i=0; $i < scalar(@ostatics); $i++) {
246         unless ( grep($_ eq $ostatics[$i], @istatics) ) {
247             splice(@ostatics,$i,1);
248             $i--;
249             $staticsChanged = 1;
250         }
251     }
252     $svc_dsl->staticips(join(',',@ostatics)) if $staticsChanged;
253
254     # 7. notes - put them into the common format and compare
255     my $tnotes = $result->{'OrderNotes'}; 
256     my @tnotes = defined $tnotes ? @$tnotes : ();
257     my @inotes = (); # all Ikano OrderNotes as FS::dsl_note objects
258     my $notesChanged = 0; 
259     foreach my $tnote ( @tnotes ) {
260         my $inote = $self->ikano2fsnote($tnote,$svc_dsl->svcnum);
261         return 'Cannot parse note' unless ref($inote);
262         push @inotes, $inote;
263     }
264     my @onotes = $svc_dsl->notes;
265     # assume notes we already have don't change & no notes added from our side
266     # so using the horrible code below just find what we're missing and add it
267     foreach my $inote ( @inotes ) {
268         my $found = 0;
269         foreach my $onote ( @onotes ) {
270             if($onote->date == $inote->date && $onote->note eq $inote->note) {
271                 $found = 1;
272                 last;
273             }
274         }
275         my $error = $inote->insert unless ( $found );
276         if ( $error ) {
277           $dbh->rollback if $oldAutoCommit;
278           return "Cannot add note: $error";
279         }
280     }
281     
282     $svc_dsl->last_pull((time));
283     local $FS::svc_Common::noexport_hack = 1;
284     my $error = $svc_dsl->replace; 
285     if ( $error ) {
286       $dbh->rollback if $oldAutoCommit;
287       return "Cannot update DSL data: $error";
288     }
289
290     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
291
292     '';
293 }
294
295 sub ikano2fsnote {
296     my($self,$n,$svcnum) = (shift,shift,shift);
297     my @ikanoRequired = qw( HighPriority StaffId Date Text CompanyStaffId );
298     return '' unless defined $n->{'HighPriority'}
299                 && defined $n->{'StaffId'}
300                 && defined $n->{'CompanyStaffId'}
301                 && defined $n->{'Date'}
302                 && defined $n->{'Text'}
303                 ;
304     my $by = 'Unknown';
305     $by = "Ikano" if $n->{'CompanyStaffId'} == -1 && $n->{'StaffId'} != -1;
306     $by = "Us" if $n->{'StaffId'} == -1 && $n->{'CompanyStaffId'} != -1;
307
308     new FS::dsl_note( {
309         'svcnum' => $svcnum,
310         'author' => $by,
311         'priority' => $n->{'HighPriority'} eq 'false' ? 'N' : 'H',
312         '_date' => int(str2time($n->{'Date'})),
313         'note' => $n->{'Text'},
314      } );
315 }
316
317 # address always required for Ikano qual, TN optional (assume dry if not given)
318 sub qual {
319     my($self,$qual) = (shift,shift);
320     
321     return 'Invalid operation - Import Mode is enabled' if $self->import_mode;
322
323     my %location_hash = $qual->location_hash; 
324     return 'No address provided' unless keys %location_hash;
325
326     return 'Location kind is required' unless $location_hash{location_kind};
327
328     my $svctn = $qual->phonenum;
329
330     my $zip = $location_hash{zip};
331     $zip =~ s/(\d{5})-\d{4}/$1/;
332
333     my $result = $self->ikano_command('PREQUAL',
334       { AddressLine1 => $location_hash{address1},
335         AddressUnitType => $location_hash{location_type},
336         AddressUnitValue => $location_hash{location_number},
337         AddressCity => $location_hash{city},
338         AddressState => $location_hash{state},
339         ZipCode => $zip,
340         Country => $location_hash{country},
341         LocationType => $location_hash{location_kind},
342         PhoneNumber => length($svctn) > 1 ? $svctn : "STANDALONE",
343         RequestClientIP => '127.0.0.1',
344         CheckNetworks => $self->option('check_networks'),
345       } ); 
346     return $result unless ref($result); # error case
347     return 'Invalid prequal response' unless defined $result->{'PrequalId'};
348
349     my $qoptions = {};
350     # lame data structure traversal...
351     # don't spend much time here, just get TermsId and ProductCustomId
352     my $networks = $result->{'Network'};
353     my @networks = defined $networks ? @$networks : ();
354     my $netcount = 0;
355     foreach my $network ( @networks ) { 
356         my $productgroups = $network->{'ProductGroup'};
357         my @productgroups = defined $productgroups ? @$productgroups : ();
358         my $pgcount = 0;
359         foreach my $productgroup ( @productgroups ) {
360             my $prefix = "ikano_Network_$netcount"."_ProductGroup_$pgcount"."_";
361             $qoptions->{$prefix."TermsId"} = $productgroup->{'TermsId'};
362             my $products = $productgroup->{'Product'};
363             my @products = defined $products ? @$products : ();
364             my $prodcount = 0;
365             foreach my $product ( @products ) {
366                 $qoptions->{$prefix."Product_$prodcount"."_ProductCustomId"} = $product->{'ProductCustomId'};
367                 $prodcount++;
368             }
369             $pgcount++;
370         }
371         $netcount++;
372     }
373
374     {   'vendor_qual_id' => $result->{'PrequalId'},
375         'status' => scalar(@networks) ? 'Q' : 'D',
376         'options' => $qoptions,
377     };
378 }
379
380 sub qual_result {
381     my($self,$qual) = (shift,shift);
382     
383     my %qual_options = $qual->options;
384     my @externalids = ();
385     my( $optionname, $optionvalue );
386     while (($optionname, $optionvalue) = each %qual_options) {
387         push @externalids, $optionvalue 
388             if ( $optionname =~ /^ikano_Network_(\d+)_ProductGroup_(\d+)_Product_(\d+)_ProductCustomId$/
389                 && $optionvalue ne '' );
390     }
391
392     my %pkglist = ();
393     my %found = ();
394     my @part_pkgs = qsearch( 'part_pkg', { 'disabled' => '' } );
395     foreach my $part_pkg ( @part_pkgs ) {
396         my %vendor_pkg_ids = $part_pkg->vendor_pkg_ids;
397         my $externalid = $vendor_pkg_ids{$self->exportnum} 
398             if defined $vendor_pkg_ids{$self->exportnum};
399         if ( $externalid && grep { $_ eq $externalid } @externalids ) {
400             $pkglist{$part_pkg->pkgpart} = $part_pkg->pkg_comment;
401             $found{$externalid}++;
402         }
403     }
404
405     my %not_avail = ();
406     foreach my $externalid ( grep !$found{$_}, @externalids ) {
407       $not_avail{$externalid} = $externalid; #a better label?
408     }
409
410     { 
411       'pkglist'   => \%pkglist,
412       'not_avail' => \%not_avail,
413     };
414 }
415
416 sub quals_by_cust_and_pkg { 
417     my($self, $custnum, $pkgpart) = (shift,shift,shift);
418
419     die "invalid custnum or pkgpart"
420         unless ($custnum =~ /^\d+$/ && $pkgpart =~ /^\d+$/);
421
422     my $part_pkg = qsearchs('part_pkg', { 'pkgpart' => $pkgpart } );
423     die "no part_pkg found" unless $part_pkg;
424     my %vendor_pkg_ids = $part_pkg->vendor_pkg_ids;
425     my $external_id = $vendor_pkg_ids{$self->exportnum};
426     die "no vendor package id defined on this package" unless $external_id;
427     
428     my $extra_sql = "where custnum = $custnum or locationnum in (select "
429         . "locationnum from cust_location where custnum = $custnum)";
430     my @quals = qsearch( { 'table' => 'qual', 'extra_sql' => $extra_sql, } );
431
432     my @filtered_quals;
433     foreach my $qual ( @quals ) {
434         my %qual_options = $qual->options;
435         my( $optionname, $optionvalue );
436         while (($optionname, $optionvalue) = each %qual_options) {
437            push @filtered_quals, $qual
438               if ( $optionname =~ /^ikano_Network_(\d+)_ProductGroup_(\d+)_Product_(\d+)_ProductCustomId$/
439                     && $optionvalue eq $external_id );
440         }
441     }
442
443     @filtered_quals;
444 }
445
446 sub loop_type_long { # sub, not a method
447     my($svc_dsl) = (shift);
448     return $loopType{$svc_dsl->loop_type};
449 }
450
451 sub ikano_command {
452   my( $self, $command, $args ) = @_;
453
454   $self->loadmod;
455
456   my $ikano = Net::Ikano->new(
457     'keyid' => $self->option('keyid'),
458     'username'  => $self->option('username'),
459     'password'  => $self->option('password'),
460     'debug'    => $self->option('debug'),
461   );
462
463   $ikano->$command($args);
464 }
465
466 sub loadmod {
467   eval "use Net::Ikano;";
468   die $@ if $@;
469 }
470
471 sub valid_order {
472   my( $self, $svc_dsl, $action ) = (shift, shift, shift);
473  
474   $self->loadmod;
475   
476   warn "$me valid_order action=$action svc_dsl:\n". Dumper($svc_dsl)
477         if $self->option('debug');
478
479   # common to all order types/status/loop_type
480   return 'No desired_due_date' unless $svc_dsl->desired_due_date; 
481   return 'Unknown vendor_order_type'
482     unless grep $_ eq $svc_dsl->vendor_order_type, Net::Ikano->orderTypes;
483   return 'No first name' unless $svc_dsl->first;
484   return 'No last name' unless $svc_dsl->get('last');
485   return 'No loop type' unless defined $svc_dsl->loop_type;
486   return 'No vendor_qual_id' unless $svc_dsl->vendor_qual_id;
487
488   my %vendor_pkg_ids = $svc_dsl->cust_svc->cust_pkg->part_pkg->vendor_pkg_ids;
489   return 'Package does not have an external id configured'
490     unless defined $vendor_pkg_ids{$self->exportnum};
491
492   return 'No valid qualification for this order' 
493     unless qsearch( 'qual', { 'vendor_qual_id' => $svc_dsl->vendor_qual_id });
494
495   # now go by order type
496   # weird ifs & long lines for readability and ease of understanding - don't change
497   if($svc_dsl->vendor_order_type eq 'NEW') {
498     if($svc_dsl->pushed) {
499         my $error = !( ($action eq 'pull' || $action eq 'statuschg' 
500                         || $action eq 'delete' || $action eq 'expire')
501             &&  length($svc_dsl->vendor_order_id) > 0
502             &&  length($svc_dsl->vendor_order_status) > 0
503                 );
504         return 'Invalid order data' if $error;
505
506         return 'Phone number required for status change'
507             if ($action eq 'statuschg' && length($svc_dsl->phonenum) < 1);
508     }
509     else { # unpushed New order - cannot do anything other than push it
510         my $error = !($action eq 'insert'
511             &&  length($svc_dsl->vendor_order_id) < 1
512             &&  length($svc_dsl->vendor_order_status) < 1
513             && ( ($svc_dsl->phonenum eq '' && $svc_dsl->loop_type eq '0') # dry
514               || ($svc_dsl->phonenum ne '' && $svc_dsl->loop_type eq '') # line-share
515                )
516             );  
517         return 'Invalid order data' if $error;
518     }
519   }
520   elsif($svc_dsl->vendor_order_type eq 'CANCEL') {
521   }
522   elsif($svc_dsl->vendor_order_type eq 'CHANGE') {
523   }
524
525  '';
526 }
527
528 sub qual2termsid {
529     my ($self,$vendor_qual_id,$ProductCustomId) = (shift,shift,shift);
530     my $qual = qsearchs( 'qual', { 'vendor_qual_id' => $vendor_qual_id });
531     return '' unless $qual;
532     my %qual_options = $qual->options;
533     my( $optionname, $optionvalue );
534     while (($optionname, $optionvalue) = each %qual_options) {
535         if ( $optionname =~ /^ikano_Network_(\d+)_ProductGroup_(\d+)_Product_(\d+)_ProductCustomId$/ 
536             && $optionvalue eq $ProductCustomId ) {
537             my $network = $1;
538             my $productgroup = $2;
539             return $qual->option("ikano_Network_".$network."_ProductGroup_".$productgroup."_TermsId");
540         }
541     }
542     '';
543 }
544
545 sub _export_insert {
546   my( $self, $svc_dsl ) = (shift, shift);
547
548   if($self->import_mode) {
549       warn "$me _export_insert: import mode" if $self->option('debug');
550       $svc_dsl->pushed((time)-2);
551       $svc_dsl->last_pull((time)-1); 
552       return $self->dsl_pull($svc_dsl,0,1);
553   }
554
555   my $result = $self->valid_order($svc_dsl,'insert');
556   return $result unless $result eq '';
557
558   my $isp_chg = $svc_dsl->isp_chg eq 'Y' ? 'YES' : 'NO';
559   my $cust_main = $svc_dsl->cust_svc->cust_pkg->cust_main;
560   my $contactTN = $cust_main->daytime || $cust_main->night || '5555555555';
561   $contactTN =~ s/[^0-9]//g;
562
563   my %vendor_pkg_ids = $svc_dsl->cust_svc->cust_pkg->part_pkg->vendor_pkg_ids;
564   my $ProductCustomId = $vendor_pkg_ids{$self->exportnum};
565
566   my $args = {
567         orderType => 'NEW',
568         ProductCustomId => $ProductCustomId,
569         TermsId => $self->qual2termsid($svc_dsl->vendor_qual_id,$ProductCustomId),
570         DSLPhoneNumber => $svc_dsl->loop_type eq '0' ? 'STANDALONE'
571                                                     : $svc_dsl->phonenum,
572         Password => $svc_dsl->password,
573         PrequalId => $svc_dsl->vendor_qual_id,
574         CompanyName => $svc_dsl->company,
575         FirstName => $svc_dsl->first,
576         LastName => $svc_dsl->last,
577         MiddleName => '',
578         ContactMethod => 'PHONE',
579         ContactPhoneNumber => $contactTN,
580         ContactEmail => 'x@x.xx',
581         ContactFax => '',
582         DateToOrder => time2str("%Y-%m-%d",$svc_dsl->desired_due_date),
583         RequestClientIP => '127.0.0.1',
584         IspChange => $isp_chg,
585         IspPrevious => $isp_chg eq 'YES' ? $svc_dsl->isp_prev : '',
586         CurrentProvider => $isp_chg eq 'NO' ? $svc_dsl->isp_prev : '',
587   };
588
589   $result = $self->ikano_command('ORDER',$args); 
590   return $result unless ref($result); # scalar (string) is an error
591
592   # now we're getting an OrderResponse which should have one Order in it
593   warn "$me _export_insert OrderResponse hash:\n".Dumper($result)
594         if $self->option('debug');
595   
596   return 'Invalid order response' unless defined $result->{'Order'};
597   $result = $result->{'Order'};
598
599   return 'No/invalid order id or status returned' 
600     unless defined $result->{'Status'} && defined $result->{'OrderId'}
601         && grep($_ eq $result->{'Status'}, @Net::Ikano::orderStatus);
602
603   $svc_dsl->pushed(time);
604   $svc_dsl->last_pull((time)+1); 
605   $svc_dsl->vendor_order_id($result->{'OrderId'});
606   $svc_dsl->vendor_order_status($result->{'Status'});
607   $svc_dsl->username($result->{'Username'});
608   local $FS::svc_Common::noexport_hack = 1;
609   $result = $svc_dsl->replace; 
610   return "Error setting DSL fields: $result" if $result;
611   '';
612 }
613
614 sub _export_replace {
615   my( $self, $new, $old ) = (shift, shift, shift);
616     
617   return 'Invalid operation - Import Mode is enabled' if $self->import_mode;
618
619 # XXX only supports password changes now, but should return error if 
620 # another change is attempted?
621
622   if($new->password ne $old->password) {
623       my $result = $self->valid_order($new,'statuschg');
624       return $result unless $result eq '';
625       
626       $result = $self->ikano_command('PASSWORDCHANGE',
627             { DSLPhoneNumber => $new->phonenum,
628               NewPassword => $new->password,
629             } ); 
630       return $result unless ref($result); # scalar (string) is an error
631
632       return 'Error changing password' unless defined $result->{'Password'}
633         && $result->{'Password'} eq $new->password;
634   }
635
636   '';
637 }
638
639 sub _export_delete {
640   my( $self, $svc_dsl ) = (shift, shift);
641   
642   return 'Invalid operation - Import Mode is enabled' if $self->import_mode;
643
644   my $result = $self->valid_order($svc_dsl,'delete');
645   return $result unless $result eq '';
646
647   # for now allow an immediate cancel only on New orders in New/Pending status
648   #XXX: add support for Change and Cancel orders in New/Pending status later
649
650   if($svc_dsl->vendor_order_type eq 'NEW') {
651     if($svc_dsl->vendor_order_status eq 'NEW' 
652             || $svc_dsl->vendor_order_status eq 'PENDING') {
653         my $result = $self->ikano_command('CANCEL', 
654             { OrderId => $svc_dsl->vendor_order_id, } );
655         return $result unless ref($result); # scalar (string) is an error
656         return 'Unable to cancel order' unless $result->{'Order'};
657         $result = $result->{'Order'};
658         return 'Invalid cancellation response' 
659             unless $result->{'Status'} eq 'CANCELLED' 
660                 && $result->{'OrderId'} eq $svc_dsl->vendor_order_id;
661
662         # we're supposed to do a pull, but it will break everything, so don't
663         # this is very wrong...
664     }
665     else {
666         return "Cannot cancel a NEW order unless it's in NEW or PENDING status";
667     }
668   }
669   elsif($svc_dsl->vendor_order_type eq 'CANCEL') {
670      return 'Cannot cancel a CANCEL order unless expire was set'
671         unless $svc_dsl->cust_svc->cust_pkg->expire > 0;
672   }
673   else {
674     return 'Canceling orders other than NEW orders is not currently implemented';
675   }
676
677   '';
678 }
679
680 sub export_expire {
681   my($self, $svc_dsl, $date) = (shift, shift, shift);
682
683   if ( $FS::svc_Common::noexport_hack ) {
684       carp 'export_expire() suppressed by noexport_hack'
685         if $self->option('debug');
686       return;
687   }
688
689   return 'Invalid operation - Import Mode is enabled' if $self->import_mode;
690
691   my $result = $self->valid_order($svc_dsl,'expire');
692   return $result unless $result eq '';
693   
694   # for now allow a proper cancel only on New orders in Completed status
695   #XXX: add support for some other cases in future
696   
697   if($svc_dsl->vendor_order_type eq 'NEW' 
698         && $svc_dsl->vendor_order_status eq 'COMPLETED') {
699     
700           my $contactTN = $svc_dsl->cust_svc->cust_pkg->cust_main->daytime;
701           $contactTN =~ s/[^0-9]//g;
702
703           my %vendor_pkg_ids = $svc_dsl->cust_svc->cust_pkg->part_pkg->vendor_pkg_ids;
704           my $ProductCustomId = $vendor_pkg_ids{$self->exportnum};
705
706           # we are now a cancel order
707           $svc_dsl->desired_due_date($date);
708           $svc_dsl->vendor_order_type('CANCEL');
709
710           my $args = {
711                 orderType => 'CANCEL',
712                 ProductCustomId => $ProductCustomId,
713                 TermsId => $self->qual2termsid($svc_dsl->vendor_qual_id,$ProductCustomId),
714                 DSLPhoneNumber => $svc_dsl->loop_type eq '0' ? 'STANDALONE'
715                                                             : $svc_dsl->phonenum,
716                 Password => $svc_dsl->password,
717                 PrequalId => $svc_dsl->vendor_qual_id,
718                 CompanyName => $svc_dsl->company,
719                 FirstName => $svc_dsl->first,
720                 LastName => $svc_dsl->last,
721                 MiddleName => '',
722                 ContactMethod => 'PHONE',
723                 ContactPhoneNumber => $contactTN,
724                 ContactEmail => 'x@x.xx',
725                 ContactFax => '',
726                 DateToOrder => time2str("%Y-%m-%d",$date),
727                 RequestClientIP => '127.0.0.1',
728                 IspChange => 'NO',
729                 IspPrevious => '',
730                 CurrentProvider => '',
731           };
732
733           $args->{'VirtualPhoneNumber'} = $svc_dsl->phonenum 
734             if $svc_dsl->loop_type eq '0';
735
736           $result = $self->ikano_command('ORDER',$args); 
737           return $result unless ref($result); # scalar (string) is an error
738
739           # now we're getting an OrderResponse which should have one Order in it
740           warn "$me _export_insert OrderResponse hash:\n".Dumper($result)
741                 if $self->option('debug');
742           
743           return 'Invalid order response' unless defined $result->{'Order'};
744           $result = $result->{'Order'};
745
746           return 'No/invalid order id or status returned' 
747             unless defined $result->{'Status'} && defined $result->{'OrderId'}
748                 && grep($_ eq $result->{'Status'}, @Net::Ikano::orderStatus);
749
750           $svc_dsl->pushed(time);
751           $svc_dsl->last_pull((time)+1); 
752           $svc_dsl->vendor_order_id($result->{'OrderId'});
753           $svc_dsl->vendor_order_status($result->{'Status'});
754           $svc_dsl->monitored('Y');
755           local $FS::svc_Common::noexport_hack = 1;
756           $result = $svc_dsl->replace; 
757           return "Error setting DSL fields: $result" if $result;
758   }
759   else {
760     return "Cancelling anything other than NEW orders in COMPLETED status is "
761         . "not currently implemented";
762   }
763  '';
764 }
765
766 sub statuschg {
767   my( $self, $svc_dsl, $type ) = (shift, shift, shift);
768   
769   return 'Invalid operation - Import Mode is enabled' if $self->import_mode;
770
771   my $result = $self->valid_order($svc_dsl,'statuschg');
772   return $result unless $result eq '';
773
774   # get the DSLServiceId
775   $result = $self->ikano_command('CUSTOMERLOOKUP',
776         { PhoneNumber => $svc_dsl->phonenum } ); 
777   return $result unless ref($result); # scalar (string) is an error
778   return 'No DSLServiceId found' unless defined $result->{'DSLServiceId'};
779   my $DSLServiceId = $result->{'DSLServiceId'};
780
781   $result = $self->ikano_command('ACCOUNTSTATUSCHANGE',
782         { DSLPhoneNumber => $svc_dsl->phonenum,
783           DSLServiceId => $DSLServiceId,
784           type => $type,
785         } ); 
786   return $result unless ref($result); # scalar (string) is an error
787   ''; 
788 }
789
790 sub _export_suspend {
791   my( $self, $svc_dsl ) = (shift, shift);
792   $self->statuschg($svc_dsl,"SUSPEND");
793 }
794
795 sub _export_unsuspend {
796   my( $self, $svc_dsl ) = (shift, shift);
797   $self->statuschg($svc_dsl,"UNSUSPEND");
798 }
799
800 1;