add dsl_device to track mac addresses, RT#13656
[freeside.git] / FS / FS / ClientAPI / MyAccount.pm
1 package FS::ClientAPI::MyAccount;
2
3 use 5.008; #require 5.8+ for Time::Local 1.05+
4 use strict;
5 use vars qw( $cache $DEBUG $me );
6 use subs qw( _cache _provision );
7 use Data::Dumper;
8 use Digest::MD5 qw(md5_hex);
9 use Date::Format;
10 use Business::CreditCard;
11 use Time::Duration;
12 use Time::Local qw(timelocal_nocheck);
13 use FS::UI::Web::small_custview qw(small_custview); #less doh
14 use FS::UI::Web;
15 use FS::UI::bytecount qw( display_bytecount );
16 use FS::Conf;
17 #use FS::UID qw(dbh);
18 use FS::Record qw(qsearch qsearchs dbh);
19 use FS::Msgcat qw(gettext);
20 use FS::Misc qw(card_types);
21 use FS::Misc::DateTime qw(parse_datetime);
22 use FS::ClientAPI_SessionCache;
23 use FS::cust_svc;
24 use FS::svc_acct;
25 use FS::svc_domain;
26 use FS::svc_phone;
27 use FS::svc_external;
28 use FS::svc_dsl;
29 use FS::dsl_device;
30 use FS::part_svc;
31 use FS::cust_main;
32 use FS::cust_bill;
33 use FS::cust_main_county;
34 use FS::cust_pkg;
35 use FS::payby;
36 use FS::acct_rt_transaction;
37 use HTML::Entities;
38 use FS::TicketSystem;
39 use Text::CSV_XS;
40 use IO::Scalar;
41 use Spreadsheet::WriteExcel;
42
43 $DEBUG = 0;
44 $me = '[FS::ClientAPI::MyAccount]';
45
46 use vars qw( @cust_main_editable_fields );
47 @cust_main_editable_fields = qw(
48   first last company address1 address2 city
49     county state zip country
50     daytime night fax mobile
51   ship_first ship_last ship_company ship_address1 ship_address2 ship_city
52     ship_state ship_zip ship_country
53     ship_daytime ship_night ship_fax ship_mobile
54   locale
55   payby payinfo payname paystart_month paystart_year payissue payip
56   ss paytype paystate stateid stateid_state
57 );
58
59 BEGIN { #preload to reduce time customer_info takes
60   if ( $FS::TicketSystem::system ) {
61     warn "$me: initializing ticket system\n" if $DEBUG;
62     FS::TicketSystem->init();
63   }
64 }
65
66 sub _cache {
67   $cache ||= new FS::ClientAPI_SessionCache( {
68                'namespace' => 'FS::ClientAPI::MyAccount',
69              } );
70 }
71
72 sub skin_info {
73   my $p = shift;
74
75   my($context, $session, $custnum) = _custoragent_session_custnum($p);
76   #return { 'error' => $session } if $context eq 'error';
77
78   my $agentnum = '';
79   if ( $context eq 'customer' ) {
80
81     my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?')
82       or die dbh->errstr;
83
84     $sth->execute($custnum) or die $sth->errstr;
85
86     $agentnum = $sth->fetchrow_arrayref->[0]
87       or die "no agentnum for custnum $custnum";
88
89   #} elsif ( $context eq 'agent' ) {
90   } elsif ( defined($p->{'agentnum'}) and $p->{'agentnum'} =~ /^(\d+)$/ ) {
91     $agentnum = $1;
92   }
93
94   my $conf = new FS::Conf;
95
96   #false laziness w/Signup.pm
97
98   my $skin_info_cache_agent = _cache->get("skin_info_cache_agent$agentnum");
99
100   if ( $skin_info_cache_agent ) {
101
102     warn "$me loading cached skin info for agentnum $agentnum\n"
103       if $DEBUG > 1;
104
105   } else {
106
107     warn "$me populating skin info cache for agentnum $agentnum\n"
108       if $DEBUG > 1;
109
110     $skin_info_cache_agent = {
111       'agentnum' => $agentnum,
112       ( map { $_ => scalar( $conf->config($_, $agentnum) ) }
113         qw( company_name date_format ) ),
114       ( map { $_ => scalar( $conf->config("selfservice-$_", $agentnum ) ) }
115         qw( body_bgcolor box_bgcolor
116             text_color link_color vlink_color hlink_color alink_color
117             font title_color title_align title_size menu_bgcolor menu_fontsize
118           )
119       ),
120       ( map { $_ => $conf->exists("selfservice-$_", $agentnum ) }
121         qw( menu_skipblanks menu_skipheadings menu_nounderline )
122       ),
123       ( map { $_ => scalar($conf->config_binary("selfservice-$_", $agentnum)) }
124         qw( title_left_image title_right_image
125             menu_top_image menu_body_image menu_bottom_image
126           )
127       ),
128       'logo' => scalar($conf->config_binary('logo.png', $agentnum )),
129       ( map { $_ => join("\n", $conf->config("selfservice-$_", $agentnum ) ) }
130         qw( head body_header body_footer company_address ) ),
131     };
132
133     _cache->set("skin_info_cache_agent$agentnum", $skin_info_cache_agent);
134
135   }
136
137   #{ %$skin_info_cache_agent };
138   $skin_info_cache_agent;
139
140 }
141
142 sub login_info {
143   my $p = shift;
144
145   my $conf = new FS::Conf;
146
147   my %info = (
148     %{ skin_info($p) },
149     'phone_login'  => $conf->exists('selfservice_server-phone_login'),
150     'single_domain'=> scalar($conf->config('selfservice_server-single_domain')),
151   );
152
153   return \%info;
154
155 }
156
157 #false laziness w/FS::ClientAPI::passwd::passwd
158 sub login {
159   my $p = shift;
160
161   my $conf = new FS::Conf;
162
163   my $svc_x = '';
164   if ( $p->{'domain'} eq 'svc_phone'
165        && $conf->exists('selfservice_server-phone_login') ) { 
166
167     my $svc_phone = qsearchs( 'svc_phone', { 'phonenum' => $p->{'username'} } );
168     return { error => 'Number not found.' } unless $svc_phone;
169
170     #XXX?
171     #my $pkg_svc = $svc_acct->cust_svc->pkg_svc;
172     #return { error => 'Only primary user may log in.' } 
173     #  if $conf->exists('selfservice_server-primary_only')
174     #    && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' );
175
176     return { error => 'Incorrect PIN.' }
177       unless $svc_phone->check_pin($p->{'password'});
178
179     $svc_x = $svc_phone;
180
181   } else {
182
183     my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
184       or return { error => 'Domain '. $p->{'domain'}. ' not found' };
185
186     my $svc_acct = qsearchs( 'svc_acct', { 'username'  => $p->{'username'},
187                                            'domsvc'    => $svc_domain->svcnum, }
188                            );
189     return { error => 'User not found.' } unless $svc_acct;
190
191     if($conf->exists('selfservice_server-login_svcpart')) {
192         my @svcpart = $conf->config('selfservice_server-login_svcpart');
193         my $svcpart = $svc_acct->cust_svc->svcpart;
194         return { error => 'Invalid user.' } 
195             unless grep($_ eq $svcpart, @svcpart);
196     }
197
198     return { error => 'Incorrect password.' }
199       unless $svc_acct->check_password($p->{'password'});
200
201     $svc_x = $svc_acct;
202
203   }
204
205   my $session = {
206     'svcnum' => $svc_x->svcnum,
207   };
208
209   my $cust_svc = $svc_x->cust_svc;
210   my $cust_pkg = $cust_svc->cust_pkg;
211   if ( $cust_pkg ) {
212     my $cust_main = $cust_pkg->cust_main;
213     $session->{'custnum'} = $cust_main->custnum;
214     if ( $conf->exists('pkg-balances') ) {
215       my @cust_pkg = grep { $_->part_pkg->freq !~ /^(0|$)/ }
216                           $cust_main->ncancelled_pkgs;
217       $session->{'pkgnum'} = $cust_pkg->pkgnum
218         if scalar(@cust_pkg) > 1;
219     }
220   }
221
222   #my $pkg_svc = $svc_acct->cust_svc->pkg_svc;
223   #return { error => 'Only primary user may log in.' } 
224   #  if $conf->exists('selfservice_server-primary_only')
225   #    && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' );
226   my $part_pkg = $cust_pkg->part_pkg;
227   return { error => 'Only primary user may log in.' }
228     if $conf->exists('selfservice_server-primary_only')
229        && $cust_svc->svcpart != $part_pkg->svcpart([qw( svc_acct svc_phone )]);
230
231   my $session_id;
232   do {
233     $session_id = md5_hex(md5_hex(time(). {}. rand(). $$))
234   } until ( ! defined _cache->get($session_id) ); #just in case
235
236   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour';
237   _cache->set( $session_id, $session, $timeout );
238
239   return { 'error'      => '',
240            'session_id' => $session_id,
241          };
242 }
243
244 sub logout {
245   my $p = shift;
246   if ( $p->{'session_id'} ) {
247     _cache->remove($p->{'session_id'});
248     return { %{ skin_info($p) }, 'error' => '' };
249   } else {
250     return { %{ skin_info($p) }, 'error' => "Can't resume session" }; #better error message
251   }
252 }
253
254 sub payment_gateway {
255   # internal use only
256   # takes a cust_main and a cust_payby entry, returns the payment_gateway
257   my $conf = new FS::Conf;
258   my $cust_main = shift;
259   my $cust_payby = shift;
260   my $gatewaynum = $conf->config('selfservice-payment_gateway');
261   if ( $gatewaynum ) {
262     my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
263     die "configured gatewaynum $gatewaynum not found!" if !$pg;
264     return $pg;
265   }
266   else {
267     return '' if ! FS::payby->realtime($cust_payby);
268     my $pg = $cust_main->agent->payment_gateway(
269       'method'  => FS::payby->payby2bop($cust_payby),
270       'nofatal' => 1
271     );
272     return $pg;
273   }
274 }
275
276 sub access_info {
277   my $p = shift;
278
279   my $conf = new FS::Conf;
280
281   my $info = skin_info($p);
282
283   use vars qw( $cust_paybys ); #cache for performance
284   unless ( $cust_paybys ) {
285
286     my %cust_paybys = map { $_ => 1 }
287                       map { FS::payby->payby2payment($_) }
288                           $conf->config('signup_server-payby');
289
290     $cust_paybys = [ keys %cust_paybys ];
291
292   }
293   $info->{'cust_paybys'} = $cust_paybys;
294
295   my($context, $session, $custnum) = _custoragent_session_custnum($p);
296   return { 'error' => $session } if $context eq 'error';
297
298   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
299     or return { 'error' => "unknown custnum $custnum" };
300
301   $info->{'hide_payment_fields'} = [ 
302     map { 
303       my $pg = payment_gateway($cust_main, $_);
304       $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
305     } @{ $info->{cust_paybys} }
306   ];
307
308   $info->{'self_suspend_reason'} = 
309       $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum);
310
311   $info->{'edit_ticket_subject'} =
312       $conf->exists('ticket_system-selfservice_edit_subject') && 
313       $cust_main->edit_subject;
314
315   return { %$info,
316            'custnum'       => $custnum,
317            'access_pkgnum' => $session->{'pkgnum'},
318            'access_svcnum' => $session->{'svcnum'},
319          };
320 }
321
322 sub customer_info {
323   my $p = shift;
324
325   my($context, $session, $custnum) = _custoragent_session_custnum($p);
326   return { 'error' => $session } if $context eq 'error';
327
328   my %return;
329
330   my $conf = new FS::Conf;
331   if ($conf->exists('cust_main-require_address2')) {
332     $return{'require_address2'} = '1';
333   }else{
334     $return{'require_address2'} = '';
335   }
336
337   if ( $FS::TicketSystem::system ) {
338     warn "$me customer_info: initializing ticket system\n" if $DEBUG;
339     FS::TicketSystem->init();
340   }
341  
342   if ( $custnum ) { #customer record
343
344     my $search = { 'custnum' => $custnum };
345     $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
346     my $cust_main = qsearchs('cust_main', $search )
347       or return { 'error' => "unknown custnum $custnum" };
348
349     if ( $session->{'pkgnum'} ) { 
350       $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
351     } else {
352       $return{balance} = $cust_main->balance;
353     }
354
355     my @tickets = $cust_main->tickets;
356     # unavoidable false laziness w/ httemplate/view/cust_main/tickets.html
357     if ( FS::TicketSystem->selfservice_priority ) {
358       my $dir = $conf->exists('ticket_system-priority_reverse') ? -1 : 1;
359       $return{tickets} = [ 
360         sort { 
361           (
362             ($a->{'_selfservice_priority'} eq '') <=>
363             ($b->{'_selfservice_priority'} eq '')
364           ) ||
365           ( $dir * 
366             ($b->{'_selfservice_priority'} <=> $a->{'_selfservice_priority'}) 
367           )
368         } @tickets
369       ];
370     }
371     else {
372       $return{tickets} = \@tickets;
373     }
374
375     unless ( $session->{'pkgnum'} ) {
376       my @open = map {
377                        {
378                          invnum => $_->invnum,
379                          date   => time2str("%b %o, %Y", $_->_date),
380                          owed   => $_->owed,
381                        };
382                      } $cust_main->open_cust_bill;
383       $return{open_invoices} = \@open;
384     }
385
386     $return{small_custview} =
387       small_custview( $cust_main,
388                       scalar($conf->config('countrydefault')),
389                       ( $session->{'pkgnum'} ? 1 : 0 ), #nobalance
390                     );
391
392     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
393     $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
394
395     for (@cust_main_editable_fields) {
396       $return{$_} = $cust_main->get($_);
397     }
398
399     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
400       $return{payinfo} = $cust_main->paymask;
401       @return{'month', 'year'} = $cust_main->paydate_monthyear;
402     }
403
404     $return{'invoicing_list'} =
405       join(', ', grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list );
406     $return{'postal_invoicing'} =
407       0 < ( grep { $_ eq 'POST' } $cust_main->invoicing_list );
408
409     if (scalar($conf->config('support_packages'))) {
410       my @support_services = ();
411       foreach ($cust_main->support_services) {
412         my $seconds = $_->svc_x->seconds;
413         my $time_remaining = (($seconds < 0) ? '-' : '' ).
414                              int(abs($seconds)/3600)."h".
415                              sprintf("%02d",(abs($seconds)%3600)/60)."m";
416         my $cust_pkg = $_->cust_pkg;
417         my $pkgnum = '';
418         my $pkg = '';
419         $pkgnum = $cust_pkg->pkgnum if $cust_pkg;
420         $pkg = $cust_pkg->part_pkg->pkg if $cust_pkg;
421         push @support_services, { svcnum => $_->svcnum,
422                                   time => $time_remaining,
423                                   pkgnum => $pkgnum,
424                                   pkg => $pkg,
425                                 };
426       }
427       $return{support_services} = \@support_services;
428     }
429
430     if ( $conf->config('prepayment_discounts-credit_type') ) {
431       #need to eval?
432       $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
433     }
434
435     if ( $session->{'svcnum'} ) {
436       my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $session->{'svcnum'} });
437       $return{'svc_label'} = ($cust_svc->label)[1] if $cust_svc;
438       $return{'svcnum'} = $session->{'svcnum'};
439     }
440
441   } elsif ( $session->{'svcnum'} ) { #no customer record
442
443     my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $session->{'svcnum'} } )
444       or die "unknown svcnum";
445     $return{name} = $svc_acct->email;
446
447   } else {
448
449     return { 'error' => 'Expired session' }; #XXX redirect to login w/this err!
450
451   }
452
453   return { 'error'          => '',
454            'custnum'        => $custnum,
455            %return,
456          };
457
458 }
459
460 sub customer_info_short {
461   my $p = shift;
462
463   my($context, $session, $custnum) = _custoragent_session_custnum($p);
464   return { 'error' => $session } if $context eq 'error';
465
466   my %return;
467
468   my $conf = new FS::Conf;
469
470   if ( $custnum ) { #customer record
471
472     my $search = { 'custnum' => $custnum };
473     $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
474     my $cust_main = qsearchs('cust_main', $search )
475       or return { 'error' => "unknown custnum $custnum" };
476
477     $return{small_custview} =
478       small_custview( $cust_main,
479                       scalar($conf->config('countrydefault')),
480                       1, ##nobalance
481                     );
482
483     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
484     $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
485
486     $return{payby} = $cust_main->payby;
487
488     #none of these are terribly expensive if we want 'em...
489     for (@cust_main_editable_fields) {
490       $return{$_} = $cust_main->get($_);
491     }
492     
493     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
494       $return{payinfo} = $cust_main->paymask;
495       @return{'month', 'year'} = $cust_main->paydate_monthyear;
496     }
497     
498     $return{'invoicing_list'} =
499       join(', ', grep { $_ !~ /^(POST|FAX)$/ } $cust_main->invoicing_list );
500     #$return{'postal_invoicing'} =
501     #  0 < ( grep { $_ eq 'POST' } $cust_main->invoicing_list );
502
503     if ( $session->{'svcnum'} ) {
504       my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $session->{'svcnum'} });
505       $return{'svc_label'} = ($cust_svc->label)[1] if $cust_svc;
506       $return{'svcnum'} = $session->{'svcnum'};
507     }
508
509   } elsif ( $session->{'svcnum'} ) { #no customer record
510
511     #uuh, not supproted yet... die?
512     return { 'error' => 'customer_info_short not yet supported as agent' };
513
514   } else {
515
516     return { 'error' => 'Expired session' }; #XXX redirect to login w/this err!
517
518   }
519
520   return { 'error'          => '',
521            'custnum'        => $custnum,
522            %return,
523          };
524 }
525
526 sub edit_info {
527   my $p = shift;
528   my $session = _cache->get($p->{'session_id'})
529     or return { 'error' => "Can't resume session" }; #better error message
530
531   my $custnum = $session->{'custnum'}
532     or return { 'error' => "no customer record" };
533
534   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
535     or return { 'error' => "unknown custnum $custnum" };
536
537   my $new = new FS::cust_main { $cust_main->hash };
538   $new->set( $_ => $p->{$_} )
539     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
540
541   my $payby = '';
542   if (exists($p->{'payby'})) {
543     $p->{'payby'} =~ /^([A-Z]{4})$/
544       or return { 'error' => "illegal_payby " . $p->{'payby'} };
545     $payby = $1;
546   }
547
548   if ( $payby =~ /^(CARD|DCRD)$/ ) {
549
550     $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01');
551
552     if ( $new->payinfo eq $cust_main->paymask ) {
553       $new->payinfo($cust_main->payinfo);
554     } else {
555       $new->payinfo($p->{'payinfo'});
556     }
557
558     $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
559
560   } elsif ( $payby =~ /^(CHEK|DCHK)$/ ) {
561
562     my $payinfo;
563     $p->{'payinfo1'} =~ /^([\dx]+)$/
564       or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
565     my $payinfo1 = $1;
566      $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by -require-bank-branch?
567       or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
568     my $payinfo2 = $1;
569     $payinfo = $payinfo1. '@'. $payinfo2;
570
571     $new->payinfo( ($payinfo eq $cust_main->paymask)
572                      ? $cust_main->payinfo
573                      : $payinfo
574                  );
575
576     $new->set( 'payby' => $p->{'auto'} ? 'CHEK' : 'DCHK' );
577
578   } elsif ( $payby =~ /^(BILL)$/ ) {
579     #no-op
580   } elsif ( $payby ) {  #notyet ready
581     return { 'error' => "unknown payby $payby" };
582   }
583
584   my @invoicing_list;
585   if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) {
586     #false laziness with httemplate/edit/process/cust_main.cgi
587     @invoicing_list = split( /\s*\,\s*/, $p->{'invoicing_list'} );
588     push @invoicing_list, 'POST' if $p->{'postal_invoicing'};
589   } else {
590     @invoicing_list = $cust_main->invoicing_list;
591   }
592
593   my $error = $new->replace($cust_main, \@invoicing_list);
594   return { 'error' => $error } if $error;
595   #$cust_main = $new;
596   
597   return { 'error' => '' };
598 }
599
600 sub payment_info {
601   my $p = shift;
602   my $session = _cache->get($p->{'session_id'})
603     or return { 'error' => "Can't resume session" }; #better error message
604
605   ##
606   #generic
607   ##
608
609   my $conf = new FS::Conf;
610   use vars qw($payment_info); #cache for performance
611   unless ( $payment_info ) {
612
613     my %states = map { $_->state => 1 }
614                    qsearch('cust_main_county', {
615                      'country' => $conf->config('countrydefault') || 'US'
616                    } );
617
618     my %cust_paybys = map { $_ => 1 }
619                       map { FS::payby->payby2payment($_) }
620                           $conf->config('signup_server-payby');
621
622     my @cust_paybys = keys %cust_paybys;
623
624     $payment_info = {
625
626       #list all counties/states/countries
627       'cust_main_county' => 
628         [ map { $_->hashref } qsearch('cust_main_county', {}) ],
629
630       #shortcut for one-country folks
631       'states' =>
632         [ sort { $a cmp $b } keys %states ],
633
634       'card_types' => card_types(),
635
636       'paytypes' => [ @FS::cust_main::paytypes ],
637
638       'paybys' => [ $conf->config('signup_server-payby') ],
639       'cust_paybys' => \@cust_paybys,
640
641       'stateid_label' => FS::Msgcat::_gettext('stateid'),
642       'stateid_state_label' => FS::Msgcat::_gettext('stateid_state'),
643
644       'show_ss'  => $conf->exists('show_ss'),
645       'show_stateid' => $conf->exists('show_stateid'),
646       'show_paystate' => $conf->exists('show_bankstate'),
647
648       'save_unchecked' => $conf->exists('selfservice-save_unchecked'),
649
650       'credit_card_surcharge_percentage' => $conf->config('credit-card-surcharge-percentage'),
651     };
652
653   }
654
655   ##
656   #customer-specific
657   ##
658
659   my %return = %$payment_info;
660
661   my $custnum = $session->{'custnum'};
662
663   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
664     or return { 'error' => "unknown custnum $custnum" };
665
666   $return{'hide_payment_fields'} = [
667     map { 
668       my $pg = payment_gateway($cust_main, $_);
669       $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
670     } @{ $return{cust_paybys} }
671   ];
672
673   $return{balance} = $cust_main->balance; #XXX pkg-balances?
674
675   $return{payname} = $cust_main->payname
676                      || ( $cust_main->first. ' '. $cust_main->get('last') );
677
678   $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
679
680   $return{payby} = $cust_main->payby;
681   $return{stateid_state} = $cust_main->stateid_state;
682
683   if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
684     $return{card_type} = cardtype($cust_main->payinfo);
685     $return{payinfo} = $cust_main->paymask;
686
687     @return{'month', 'year'} = $cust_main->paydate_monthyear;
688
689   }
690
691   if ( $cust_main->payby =~ /^(CHEK|DCHK)$/ ) {
692     my ($payinfo1, $payinfo2) = split '@', $cust_main->paymask;
693     $return{payinfo1} = $payinfo1;
694     $return{payinfo2} = $payinfo2;
695     $return{paytype}  = $cust_main->paytype;
696     $return{paystate} = $cust_main->paystate;
697
698   }
699
700   if ( $conf->config('prepayment_discounts-credit_type') ) {
701     #need to eval?
702     $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
703   }
704
705   #doubleclick protection
706   my $_date = time;
707   $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
708
709   return { 'error' => '',
710            %return,
711          };
712
713 }
714
715 #some false laziness with httemplate/process/payment.cgi - look there for
716 #ACH and CVV support stuff
717
718 sub validate_payment {
719   my $p = shift;
720
721   my $session = _cache->get($p->{'session_id'})
722     or return { 'error' => "Can't resume session" }; #better error message
723
724   my $custnum = $session->{'custnum'};
725
726   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
727     or return { 'error' => "unknown custnum $custnum" };
728
729   $p->{'amount'} =~ /^\s*(\d+(\.\d{2})?)\s*$/
730     or return { 'error' => gettext('illegal_amount') };
731   my $amount = $1;
732   return { error => 'Amount must be greater than 0' } unless $amount > 0;
733
734   $p->{'discount_term'} =~ /^\s*(\d*)\s*$/
735     or return { 'error' => gettext('illegal_discount_term'). ': '. $p->{'discount_term'} };
736   my $discount_term = $1;
737
738   $p->{'payname'} =~ /^([\w \,\.\-\']+)$/
739     or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} };
740   my $payname = $1;
741
742   $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
743     or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} };
744   my $paybatch = $1;
745
746   $p->{'payby'} ||= 'CARD';
747   $p->{'payby'} =~ /^([A-Z]{4})$/
748     or return { 'error' => "illegal_payby " . $p->{'payby'} };
749   my $payby = $1;
750
751   #false laziness w/process/payment.cgi
752   my $payinfo;
753   my $paycvv = '';
754   if ( $payby eq 'CHEK' || $payby eq 'DCHK' ) {
755   
756     $p->{'payinfo1'} =~ /^([\dx]+)$/
757       or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
758     my $payinfo1 = $1;
759      $p->{'payinfo2'} =~ /^([\dx]+)$/
760       or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
761     my $payinfo2 = $1;
762     $payinfo = $payinfo1. '@'. $payinfo2;
763
764     $payinfo = $cust_main->payinfo
765       if $cust_main->paymask eq $payinfo;
766    
767   } elsif ( $payby eq 'CARD' || $payby eq 'DCRD' ) {
768    
769     $payinfo = $p->{'payinfo'};
770
771     #more intelligent mathing will be needed here if you change
772     #card_masking_method and don't remove existing paymasks
773     $payinfo = $cust_main->payinfo
774       if $cust_main->paymask eq $payinfo;
775
776     $payinfo =~ s/\D//g;
777     $payinfo =~ /^(\d{13,16}|\d{8,9})$/
778       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
779     $payinfo = $1;
780
781     validate($payinfo)
782       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
783     return { 'error' => gettext('unknown_card_type') }
784       if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown";
785
786     if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) {
787       if ( cardtype($payinfo) eq 'American Express card' ) {
788         $p->{'paycvv'} =~ /^\s*(\d{4})\s*$/
789           or return { 'error' => "CVV2 (CID) for American Express cards is four digits." };
790         $paycvv = $1;
791       } else {
792         $p->{'paycvv'} =~ /^\s*(\d{3})\s*$/
793           or return { 'error' => "CVV2 (CVC2/CID) is three digits." };
794         $paycvv = $1;
795       }
796     }
797   
798   } else {
799     die "unknown payby $payby";
800   }
801
802   my %payby2fields = (
803     'CARD' => [ qw( paystart_month paystart_year payissue payip
804                     address1 address2 city state zip country    ) ],
805     'CHEK' => [ qw( ss paytype paystate stateid stateid_state payip ) ],
806   );
807
808   my $card_type = '';
809   $card_type = cardtype($payinfo) if $payby eq 'CARD';
810
811   { 
812     'cust_main'      => $cust_main, #XXX or just custnum??
813     'amount'         => $amount,
814     'payby'          => $payby,
815     'payinfo'        => $payinfo,
816     'paymask'        => $cust_main->mask_payinfo( $payby, $payinfo ),
817     'card_type'      => $card_type,
818     'paydate'        => $p->{'year'}. '-'. $p->{'month'}. '-01',
819     'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'},
820     'payname'        => $payname,
821     'paybatch'       => $paybatch, #this doesn't actually do anything
822     'paycvv'         => $paycvv,
823     'payname'        => $payname,
824     'discount_term'  => $discount_term,
825     'pkgnum'         => $session->{'pkgnum'},
826     map { $_ => $p->{$_} } ( @{ $payby2fields{$payby} },
827                              qw( save auto ),
828                            )
829   };
830
831 }
832
833 sub store_payment {
834   my $p = shift;
835
836   my $validate = validate_payment($p);
837   return $validate if $validate->{'error'};
838
839   my $conf = new FS::Conf;
840   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour'; #?
841   _cache->set( 'payment_'.$p->{'session_id'}, $validate, $timeout );
842
843   +{ map { $_=>$validate->{$_} }
844       qw( card_type paymask payname paydate_pretty amount )
845   };
846
847 }
848
849 sub process_stored_payment {
850   my $p = shift;
851
852   my $session_id = $p->{'session_id'};
853
854   my $payment_info = _cache->get( "payment_$session_id" )
855     or return { 'error' => "Can't resume session" }; #better error message
856
857   do_process_payment($payment_info);
858
859 }
860
861 sub process_payment {
862   my $p = shift;
863
864   my $payment_info = validate_payment($p);
865   return $payment_info if $payment_info->{'error'};
866
867   do_process_payment($payment_info);
868
869 }
870
871 sub do_process_payment {
872   my $validate = shift;
873
874   my $cust_main = $validate->{'cust_main'};
875
876   my $amount = delete $validate->{'amount'};
877   my $paynum = '';
878
879   my $payby = delete $validate->{'payby'};
880
881   my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount,
882     'quiet'       => 1,
883     'selfservice' => 1,
884     'paynum_ref'  => \$paynum,
885     %$validate,
886   );
887   return { 'error' => $error } if $error;
888
889   $cust_main->apply_payments;
890
891   if ( $validate->{'save'} ) {
892     my $new = new FS::cust_main { $cust_main->hash };
893     if ($validate->{'payby'} eq 'CARD' || $validate->{'payby'} eq 'DCRD') {
894       $new->set( $_ => $validate->{$_} )
895         foreach qw( payname paystart_month paystart_year payissue payip
896                     address1 address2 city state zip country );
897       $new->set( 'payby' => $validate->{'auto'} ? 'CARD' : 'DCRD' );
898     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK') {
899       $new->set( $_ => $validate->{$_} )
900         foreach qw( payname payip paytype paystate
901                     stateid stateid_state );
902       $new->set( 'payby' => $validate->{'auto'} ? 'CHEK' : 'DCHK' );
903     }
904     $new->set( 'payinfo' => $cust_main->card_token || $validate->{'payinfo'} );
905     $new->set( 'paydate' => $validate->{'paydate'} );
906     my $error = $new->replace($cust_main);
907     if ( $error ) {
908       #no, this causes customers to process their payments again
909       #return { 'error' => $error };
910       #XXX just warn verosely for now so i can figure out how these happen in
911       # the first place, eventually should redirect them to the "change
912       #address" page but indicate the payment did process??
913       delete($validate->{'payinfo'}); #don't want to log this!
914       warn "WARNING: error changing customer info when processing payment (not returning to customer as a processing error): $error\n".
915            "NEW: ". Dumper($new)."\n".
916            "OLD: ". Dumper($cust_main)."\n".
917            "PACKET: ". Dumper($validate)."\n";
918     #} else {
919       #not needed...
920       #$cust_main = $new;
921     }
922   }
923
924   my $cust_pay = '';
925   my $receipt_html = '';
926   if ($paynum) {
927       # currently supported for realtime CC only; send receipt data to SS
928       $cust_pay = qsearchs('cust_pay', { 'paynum' => $paynum } );
929       if($cust_pay) {
930         $receipt_html = qq!
931 <TABLE BGCOLOR="#cccccc" BORDER=0 CELLSPACING=2>
932
933 <TR>
934   <TD ALIGN="right">Payment#</TD>
935   <TD BGCOLOR="#FFFFFF"><B>! . $cust_pay->paynum . qq!</B></TD>
936 </TR>
937
938 <TR>
939   <TD ALIGN="right">Date</TD>
940
941   <TD BGCOLOR="#FFFFFF"><B>! . 
942         time2str("%a&nbsp;%b&nbsp;%o,&nbsp;%Y&nbsp;%r", $cust_pay->_date)
943                                                             . qq!</B></TD>
944 </TR>
945
946
947 <TR>
948   <TD ALIGN="right">Amount</TD>
949   <TD BGCOLOR="#FFFFFF"><B>! . sprintf('%.2f', $cust_pay->paid) . qq!</B></TD>
950
951 </TR>
952
953 <TR>
954   <TD ALIGN="right">Payment method</TD>
955   <TD BGCOLOR="#FFFFFF"><B>! . $cust_pay->payby_name .' #'. $cust_pay->paymask
956                                                                 . qq!</B></TD>
957 </TR>
958
959 </TABLE>
960 !;
961       }
962   }
963
964   if ( $cust_pay ) {
965
966     my($gw, $auth, $order) = split(':', $cust_pay->paybatch);
967
968     return {
969       'error'        => '',
970       'amount'       => sprintf('%.2f', $cust_pay->paid),
971       'date'         => $cust_pay->_date,
972       'date_pretty'  => time2str('%Y-%m-%d', $cust_pay->_date),
973       'time_pretty'  => time2str('%T', $cust_pay->_date),
974       'auth_num'     => $auth,
975       'order_num'    => $order,
976       'receipt_html' => $receipt_html,
977     };
978
979   } else {
980
981     return {
982       'error'        => '',
983       'receipt_html' => '',
984     };
985
986   }
987
988 }
989
990 sub realtime_collect {
991   my $p = shift;
992
993   my $session = _cache->get($p->{'session_id'})
994     or return { 'error' => "Can't resume session" }; #better error message
995
996   my $custnum = $session->{'custnum'};
997
998   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
999     or return { 'error' => "unknown custnum $custnum" };
1000
1001   my $amount;
1002   if ( $p->{'amount'} ) {
1003     $amount = $p->{'amount'};
1004   }
1005   elsif ( $session->{'pkgnum'} ) {
1006     $amount = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
1007   }
1008   else {
1009     $amount = $cust_main->balance;
1010   }
1011
1012   my $error = $cust_main->realtime_collect(
1013     'method'     => $p->{'method'},
1014     'amount'     => $amount,
1015     'pkgnum'     => $session->{'pkgnum'},
1016     'session_id' => $p->{'session_id'},
1017     'apply'      => 1,
1018     'selfservice'=> 1,
1019   );
1020   return { 'error' => $error } unless ref( $error );
1021
1022   return { 'error' => '', amount => $amount, %$error };
1023 }
1024
1025 sub process_payment_order_pkg {
1026   my $p = shift;
1027
1028   my $hr = process_payment($p);
1029   return $hr if $hr->{'error'};
1030
1031   order_pkg($p);
1032 }
1033
1034 sub process_payment_order_renew {
1035   my $p = shift;
1036
1037   my $hr = process_payment($p);
1038   return $hr if $hr->{'error'};
1039
1040   order_renew($p);
1041 }
1042
1043 sub process_prepay {
1044
1045   my $p = shift;
1046
1047   my $session = _cache->get($p->{'session_id'})
1048     or return { 'error' => "Can't resume session" }; #better error message
1049
1050   my %return;
1051
1052   my $custnum = $session->{'custnum'};
1053
1054   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1055     or return { 'error' => "unknown custnum $custnum" };
1056
1057   my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = ( 0, 0, 0, 0, 0 );
1058   my $error = $cust_main->recharge_prepay( $p->{'prepaid_cardnum'},
1059                                            \$amount,
1060                                            \$seconds,
1061                                            \$upbytes,
1062                                            \$downbytes,
1063                                            \$totalbytes,
1064                                          );
1065
1066   return { 'error' => $error } if $error;
1067
1068   return { 'error'     => '',
1069            'amount'    => $amount,
1070            'seconds'   => $seconds,
1071            'duration'  => duration_exact($seconds),
1072            'upbytes'   => $upbytes,
1073            'upload'    => FS::UI::bytecount::bytecount_unexact($upbytes),
1074            'downbytes' => $downbytes,
1075            'download'  => FS::UI::bytecount::bytecount_unexact($downbytes),
1076            'totalbytes'=> $totalbytes,
1077            'totalload' => FS::UI::bytecount::bytecount_unexact($totalbytes),
1078          };
1079
1080 }
1081
1082 sub invoice {
1083   my $p = shift;
1084   my $session = _cache->get($p->{'session_id'})
1085     or return { 'error' => "Can't resume session" }; #better error message
1086
1087   my $custnum = $session->{'custnum'};
1088
1089   my $invnum = $p->{'invnum'};
1090
1091   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
1092                                           'custnum' => $custnum } )
1093     or return { 'error' => "Can't find invnum" };
1094
1095   #my %return;
1096
1097   return { 'error'        => '',
1098            'invnum'       => $invnum,
1099            'invoice_text' => join('', $cust_bill->print_text ),
1100            'invoice_html' => $cust_bill->print_html( { unsquelch_cdr => 1 } ),
1101          };
1102
1103 }
1104
1105 sub invoice_pdf {
1106   my $p = shift;
1107   my $session = _cache->get($p->{'session_id'})
1108     or return { 'error' => "Can't resume session" }; #better error message
1109
1110   my $custnum = $session->{'custnum'};
1111
1112   my $invnum = $p->{'invnum'};
1113
1114   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
1115                                           'custnum' => $custnum } )
1116     or return { 'error' => "Can't find invnum" };
1117
1118   #my %return;
1119
1120   return { 'error'       => '',
1121            'invnum'      => $invnum,
1122            'invoice_pdf' => $cust_bill->print_pdf( { unsquelch_cdr => 1 } ),
1123          };
1124
1125 }
1126
1127 sub invoice_logo {
1128   my $p = shift;
1129
1130   #sessioning for this?  how do we get the session id to the backend invoice
1131   # template so it can add it to the link, blah
1132
1133   my $agentnum = '';
1134   if ( $p->{'invnum'} ) {
1135     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $p->{'invnum'} } )
1136       or return { 'error' => 'unknown invnum' };
1137     $agentnum = $cust_bill->cust_main->agentnum;
1138   }
1139
1140   my $templatename = $p->{'template'} || $p->{'templatename'};
1141
1142   #false laziness-ish w/view/cust_bill-logo.cgi
1143
1144   my $conf = new FS::Conf;
1145   if ( $templatename =~ /^([^\.\/]*)$/ && $conf->exists("logo_$1.png") ) {
1146     $templatename = "_$1";
1147   } else {
1148     $templatename = '';
1149   }
1150
1151   my $filename = "logo$templatename.png";
1152
1153   return { 'error'        => '',
1154            'logo'         => $conf->config_binary($filename, $agentnum),
1155            'content_type' => 'image/png', #should allow gif, jpg too
1156          };
1157 }
1158
1159
1160 sub list_invoices {
1161   my $p = shift;
1162   my $session = _cache->get($p->{'session_id'})
1163     or return { 'error' => "Can't resume session" }; #better error message
1164
1165   my $custnum = $session->{'custnum'};
1166
1167   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1168     or return { 'error' => "unknown custnum $custnum" };
1169
1170   my @cust_bill = $cust_main->cust_bill;
1171
1172   my $balance = 0;
1173
1174   return  { 'error'       => '',
1175             'balance'     => $cust_main->balance,
1176             'invoices'    => [
1177               map {
1178                     my $owed = $_->owed;
1179                     $balance += $owed;
1180                     +{ 'invnum'     => $_->invnum,
1181                        '_date'      => $_->_date,
1182                        'date'       => time2str("%b %o, %Y", $_->_date),
1183                        'date_short' => time2str("%m-%d-%Y",  $_->_date),
1184                        'previous'   => sprintf('%.2f', ($_->previous)[0]),
1185                        'charged'    => sprintf('%.2f', $_->charged),
1186                        'owed'       => sprintf('%.2f', $owed),
1187                        'balance'    => sprintf('%.2f', $balance),
1188                      }
1189                   }
1190                   @cust_bill
1191             ]
1192           };
1193 }
1194
1195 sub cancel {
1196   my $p = shift;
1197   my $session = _cache->get($p->{'session_id'})
1198     or return { 'error' => "Can't resume session" }; #better error message
1199
1200   my $custnum = $session->{'custnum'};
1201
1202   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1203     or return { 'error' => "unknown custnum $custnum" };
1204
1205   my @errors = $cust_main->cancel( 'quiet'=>1 );
1206
1207   my $error = scalar(@errors) ? join(' / ', @errors) : '';
1208
1209   return { 'error' => $error };
1210
1211 }
1212
1213 sub list_pkgs {
1214   my $p = shift;
1215
1216   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1217   return { 'error' => $session } if $context eq 'error';
1218
1219   my $search = { 'custnum' => $custnum };
1220   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1221   my $cust_main = qsearchs('cust_main', $search )
1222     or return { 'error' => "unknown custnum $custnum" };
1223
1224   my $conf = new FS::Conf;
1225   
1226 # the duplication below is necessary:
1227 # 1. to maintain the current buggy behaviour wrt the cust_pkg and part_pkg
1228 # hashes overwriting each other (setup and no_auto fields). Fixing that is a
1229 # non-backwards-compatible change breaking the software of anyone using the API
1230 # instead of the stock selfservice
1231 # 2. to return cancelled packages as well - for wholesale and non-wholesale
1232   if( $conf->exists('selfservice_server-view-wholesale') ) {
1233     return { 'svcnum'   => $session->{'svcnum'},
1234             'custnum'  => $custnum,
1235             'cust_pkg' => [ map {
1236                           { $_->hash,
1237                             part_pkg => [ map $_->hashref, $_->part_pkg ],
1238                             part_svc =>
1239                               [ map $_->hashref, $_->available_part_svc ],
1240                             cust_svc => 
1241                               [ map { my $ref = { $_->hash,
1242                                                   label => [ $_->label ],
1243                                                 };
1244                                       $ref->{_password} = $_->svc_x->_password
1245                                         if $context eq 'agent'
1246                                         && $conf->exists('agent-showpasswords')
1247                                         && $_->part_svc->svcdb eq 'svc_acct';
1248                                       $ref;
1249                                     } $_->cust_svc
1250                               ],
1251                           };
1252                         } $cust_main->cust_pkg
1253                   ],
1254     'small_custview' =>
1255       small_custview( $cust_main, $conf->config('countrydefault') ),
1256     'wholesale_view' => 1,
1257     'login_svcpart' => [ $conf->config('selfservice_server-login_svcpart') ],
1258     'date_format' => $conf->config('date_format') || '%m/%d/%Y',
1259     'lnp' => $conf->exists('svc_phone-lnp'),
1260       };
1261   }
1262
1263   { 'svcnum'   => $session->{'svcnum'},
1264     'custnum'  => $custnum,
1265     'cust_pkg' => [ map {
1266                           my $primary_cust_svc = $_->primary_cust_svc;
1267                           +{ $_->hash,
1268                             $_->part_pkg->hash,
1269                             status => $_->status,
1270                             part_svc =>
1271                               [ map $_->hashref, $_->available_part_svc ],
1272                             cust_svc => 
1273                               [ map { my $ref = { $_->hash,
1274                                                   label => [ $_->label ],
1275                                                 };
1276                                       $ref->{_password} = $_->svc_x->_password
1277                                         if $context eq 'agent'
1278                                         && $conf->exists('agent-showpasswords')
1279                                         && $_->part_svc->svcdb eq 'svc_acct';
1280                                       $ref->{svchash} = { $_->svc_x->hash } if 
1281                                         $_->part_svc->svcdb eq 'svc_phone';
1282                                       $ref->{svchash}->{svcpart} =  $_->part_svc->svcpart
1283                                         if $_->part_svc->svcdb eq 'svc_phone'; # hack
1284                                       $ref;
1285                                     } $_->cust_svc
1286                               ],
1287                             primary_cust_svc =>
1288                               $primary_cust_svc
1289                                 ? { $primary_cust_svc->hash,
1290                                     label => [ $primary_cust_svc->label ],
1291                                     finger => $primary_cust_svc->svc_x->finger, #uuh
1292                                     $primary_cust_svc->part_svc->hash,
1293                                   }
1294                                 : {}, #'' ?
1295                           };
1296                         } $cust_main->ncancelled_pkgs
1297                   ],
1298     'small_custview' =>
1299       small_custview( $cust_main, $conf->config('countrydefault') ),
1300   };
1301
1302 }
1303
1304 sub list_svcs {
1305   my $p = shift;
1306
1307   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1308   return { 'error' => $session } if $context eq 'error';
1309
1310   my $search = { 'custnum' => $custnum };
1311   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1312   my $cust_main = qsearchs('cust_main', $search )
1313     or return { 'error' => "unknown custnum $custnum" };
1314
1315   my $pkgnum = $session->{'pkgnum'} || $p->{'pkgnum'} || '';
1316   if ( ! $pkgnum && $p->{'svcnum'} ) {
1317     my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $p->{'svcnum'} } );
1318     $pkgnum = $cust_svc->pkgnum if $cust_svc;
1319   }
1320
1321   my @cust_svc = ();
1322   #foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) {
1323   foreach my $cust_pkg ( $p->{'ncancelled'} 
1324                          ? $cust_main->ncancelled_pkgs
1325                          : $cust_main->unsuspended_pkgs ) {
1326     next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
1327     push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
1328   }
1329   if ( $p->{'svcdb'} ) {
1330     my $svcdb = ref($p->{'svcdb'}) eq 'HASH'
1331                   ? $p->{'svcdb'}
1332                   : ref($p->{'svcdb'}) eq 'ARRAY'
1333                     ? { map { $_=>1 } @{ $p->{'svcdb'} } }
1334                     : { $p->{'svcdb'} => 1 };
1335     @cust_svc = grep $svcdb->{ $_->part_svc->svcdb }, @cust_svc
1336   }
1337
1338   #@svc_x = sort { $a->domain cmp $b->domain || $a->username cmp $b->username }
1339   #              @svc_x;
1340
1341     my $conf = new FS::Conf;
1342
1343   { 
1344     'svcnum'   => $session->{'svcnum'},
1345     'custnum'  => $custnum,
1346     'date_format' => $conf->config('date_format') || '%m/%d/%Y',
1347     'view_usage_nodomain' => $conf->exists('selfservice-view_usage_nodomain'),
1348     'svcs'     => [
1349       map { 
1350             my $svc_x = $_->svc_x;
1351             my($label, $value) = $_->label;
1352             my $svcdb = $_->part_svc->svcdb;
1353             my $part_pkg = $_->cust_pkg->part_pkg;
1354
1355             my %hash = (
1356               'svcnum' => $_->svcnum,
1357               'svcdb'  => $svcdb,
1358               'label'  => $label,
1359               'value'  => $value,
1360             );
1361
1362             if ( $svcdb eq 'svc_acct' ) {
1363               %hash = (
1364                 %hash,
1365                 'username'   => $svc_x->username,
1366                 'email'      => $svc_x->email,
1367                 'finger'     => $svc_x->finger,
1368                 'seconds'    => $svc_x->seconds,
1369                 'upbytes'    => display_bytecount($svc_x->upbytes),
1370                 'downbytes'  => display_bytecount($svc_x->downbytes),
1371                 'totalbytes' => display_bytecount($svc_x->totalbytes),
1372
1373                 'recharge_amount'  => $part_pkg->option('recharge_amount',1),
1374                 'recharge_seconds' => $part_pkg->option('recharge_seconds',1),
1375                 'recharge_upbytes'    =>
1376                   display_bytecount($part_pkg->option('recharge_upbytes',1)),
1377                 'recharge_downbytes'  =>
1378                   display_bytecount($part_pkg->option('recharge_downbytes',1)),
1379                 'recharge_totalbytes' =>
1380                   display_bytecount($part_pkg->option('recharge_totalbytes',1)),
1381                 # more...
1382               );
1383
1384             } elsif ( $svcdb eq 'svc_dsl' ) {
1385               $hash{'phonenum'} = $svc_x->phonenum;
1386               if ( $svc_x->first || $svc_x->get('last') || $svc_x->company ) {
1387                 $hash{'name'} = $svc_x->first. ' '. $svc_x->get('last');
1388                 $hash{'name'} = $svc_x->company. ' ('. $hash{'name'}. ')'
1389                   if $svc_x->company;
1390               } else {
1391                 $hash{'name'} = $cust_main->name;
1392               }
1393             }
1394             # elsif ( $svcdb eq 'svc_phone' || $svcdb eq 'svc_port' ) {
1395             #  %hash = (
1396             #    %hash,
1397             #  );
1398             #}
1399
1400             \%hash;
1401           }
1402           @cust_svc
1403     ],
1404   };
1405
1406 }
1407
1408 sub _customer_svc_x {
1409   my($custnum, $svcnum, $table) = @_;
1410
1411   $custnum =~ /^(\d+)$/ or die "illegal custnum";
1412   my $search = " AND custnum = $1";
1413   #$search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent';
1414
1415   qsearchs( {
1416     'table'     => ($table || 'svc_acct'),
1417     'addl_from' => 'LEFT JOIN cust_svc  USING ( svcnum  ) '.
1418                    'LEFT JOIN cust_pkg  USING ( pkgnum  ) ',#.
1419                    #'LEFT JOIN cust_main USING ( custnum ) ',
1420     'hashref'   => { 'svcnum' => $svcnum, },
1421     'extra_sql' => $search, #important
1422   } );
1423
1424 }
1425
1426 sub list_dsl_devices {
1427   my $p = shift;
1428
1429   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1430   return { 'error' => $session } if $context eq 'error';
1431
1432   my $svc_dsl = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl' )
1433     or return { 'error' => "Service not found" };
1434
1435   return {
1436     'devices' => [ map {
1437                          +{ 'mac_addr' => $_->mac_addr };
1438                        } $svc_dsl->dsl_device
1439                  ],
1440   };
1441
1442 }
1443
1444 sub add_dsl_device {
1445   my $p = shift;
1446
1447   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1448   return { 'error' => $session } if $context eq 'error';
1449
1450   my $svc_dsl = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl' )
1451     or return { 'error' => "Service not found" };
1452
1453   return { 'error' => 'No MAC address supplied' }
1454     unless length($p->{'mac_addr'});
1455
1456   my $dsl_device = new FS::dsl_device { 'svcnum'   => $svc_dsl->svcnum,
1457                                         'mac_addr' => scalar($p->{'mac_addr'}),
1458                                       };
1459   my $error = $dsl_device->insert;
1460   return { 'error' => $error };
1461
1462 }
1463
1464 sub delete_dsl_device {
1465   my $p = shift;
1466
1467   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1468   return { 'error' => $session } if $context eq 'error';
1469
1470   my $svc_dsl = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl' )
1471     or return { 'error' => "Service not found" };
1472
1473   my $dsl_device = qsearchs('dsl_device', { 'svcnum'   => $svc_dsl->svcnum,
1474                                             'mac_addr' => scalar($p->{'mac_addr'}),
1475                                           }
1476                            )
1477     or return { 'error' => 'Unknown MAC address: '. $p->{'mac_addr'} };
1478
1479   my $error = $dsl_device->delete;
1480   return { 'error' => $error };
1481
1482 }
1483
1484 sub port_graph {
1485   my $p = shift;
1486   _usage_details( \&_port_graph, $p,
1487                   'svcdb' => 'svc_port',
1488                 );
1489 }
1490
1491 sub _port_graph {
1492   my($svc_port, $begin, $end) = @_;
1493   my @usage = ();
1494   my $pngOrError = $svc_port->graph_png( start=>$begin, end=> $end );
1495   push @usage, { 'png' => $pngOrError };
1496   (@usage);
1497 }
1498
1499 sub _list_svc_usage {
1500   my($svc_acct, $begin, $end) = @_;
1501   my @usage = ();
1502   foreach my $part_export ( 
1503     map { qsearch ( 'part_export', { 'exporttype' => $_ } ) }
1504     qw( sqlradius sqlradius_withdomain )
1505   ) {
1506     push @usage, @ { $part_export->usage_sessions($begin, $end, $svc_acct) };
1507   }
1508   (@usage);
1509 }
1510
1511 sub list_svc_usage {
1512   _usage_details(\&_list_svc_usage, @_);
1513 }
1514
1515 sub _list_support_usage {
1516   my($svc_acct, $begin, $end) = @_;
1517   my @usage = ();
1518   foreach ( grep { $begin <= $_->_date && $_->_date <= $end }
1519             qsearch('acct_rt_transaction', { 'svcnum' => $svc_acct->svcnum })
1520           ) {
1521     push @usage, { 'seconds'  => $_->seconds,
1522                    'support'  => $_->support,
1523                    '_date'    => $_->_date,
1524                    'id'       => $_->transaction_id,
1525                    'creator'  => $_->creator,
1526                    'subject'  => $_->subject,
1527                    'status'   => $_->status,
1528                    'ticketid' => $_->ticketid,
1529                  };
1530   }
1531   (@usage);
1532 }
1533
1534 sub list_support_usage {
1535   _usage_details(\&_list_support_usage, @_);
1536 }
1537
1538 sub _list_cdr_usage {
1539   my($svc_phone, $begin, $end) = @_;
1540   map [ $_->downstream_csv('format' => 'default', 'keeparray' => 1) ], #XXX config for format
1541                        $svc_phone->get_cdrs( 'begin'=>$begin, 'end'=>$end, );
1542 }
1543
1544 sub list_cdr_usage {
1545   my $p = shift;
1546   _usage_details( \&_list_cdr_usage, $p,
1547                   'svcdb' => 'svc_phone',
1548                 );
1549 }
1550
1551 sub _usage_details {
1552   my($callback, $p, %opt) = @_;
1553
1554   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1555   return { 'error' => $session } if $context eq 'error';
1556
1557   my $search = { 'svcnum' => $p->{'svcnum'} };
1558   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1559
1560   my $svcdb = $opt{'svcdb'} || 'svc_acct';
1561
1562   my $svc_x = qsearchs( $svcdb, $search );
1563   return { 'error' => 'No service selected in list_svc_usage' } 
1564     unless $svc_x;
1565
1566   my $header = $svcdb eq 'svc_phone'
1567                  ? [ split(',', FS::cdr::invoice_header('default') ) ]  #XXX
1568                  : [];
1569
1570   my $cust_pkg = $svc_x->cust_svc->cust_pkg;
1571   my $freq     = $cust_pkg->part_pkg->freq;
1572   my $start    = $cust_pkg->setup;
1573   #my $end      = $cust_pkg->bill; # or time?
1574   my $end      = time;
1575
1576   unless ( $p->{beginning} ) {
1577     $p->{beginning} = $cust_pkg->last_bill;
1578     $p->{ending}    = $end;
1579   }
1580
1581   my (@usage) = &$callback($svc_x, $p->{beginning}, $p->{ending});
1582
1583   #kinda false laziness with FS::cust_main::bill, but perhaps
1584   #we should really change this bit to DateTime and DateTime::Duration
1585   #
1586   #change this bit to use Date::Manip? CAREFUL with timezones (see
1587   # mailing list archive)
1588   my ($nsec,$nmin,$nhour,$nmday,$nmon,$nyear) =
1589     (localtime($p->{ending}) )[0,1,2,3,4,5];
1590   my ($psec,$pmin,$phour,$pmday,$pmon,$pyear) =
1591     (localtime($p->{beginning}) )[0,1,2,3,4,5];
1592
1593   if ( $freq =~ /^\d+$/ ) {
1594     $nmon += $freq;
1595     until ( $nmon < 12 ) { $nmon -= 12; $nyear++; }
1596     $pmon -= $freq;
1597     until ( $pmon >= 0 ) { $pmon += 12; $pyear--; }
1598   } elsif ( $freq =~ /^(\d+)w$/ ) {
1599     my $weeks = $1;
1600     $nmday += $weeks * 7;
1601     $pmday -= $weeks * 7;
1602   } elsif ( $freq =~ /^(\d+)d$/ ) {
1603     my $days = $1;
1604     $nmday += $days;
1605     $pmday -= $days;
1606   } elsif ( $freq =~ /^(\d+)h$/ ) {
1607     my $hours = $1;
1608     $nhour += $hours;
1609     $phour -= $hours;
1610   } else {
1611     return { 'error' => "unparsable frequency: ". $freq };
1612   }
1613   
1614   my $previous  = timelocal_nocheck($psec,$pmin,$phour,$pmday,$pmon,$pyear);
1615   my $next      = timelocal_nocheck($nsec,$nmin,$nhour,$nmday,$nmon,$nyear);
1616
1617   { 
1618     'error'     => '',
1619     'svcnum'    => $p->{svcnum},
1620     'beginning' => $p->{beginning},
1621     'ending'    => $p->{ending},
1622     'previous'  => ($previous > $start) ? $previous : $start,
1623     'next'      => ($next < $end) ? $next : $end,
1624     'header'    => $header,
1625     'usage'     => \@usage,
1626   };
1627 }
1628
1629 sub order_pkg {
1630   my $p = shift;
1631
1632   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1633   return { 'error' => $session } if $context eq 'error';
1634
1635   my $search = { 'custnum' => $custnum };
1636   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1637   my $cust_main = qsearchs('cust_main', $search )
1638     or return { 'error' => "unknown custnum $custnum" };
1639
1640   my $status = $cust_main->status;
1641   #false laziness w/ClientAPI/Signup.pm
1642
1643   my $cust_pkg = new FS::cust_pkg ( {
1644     'custnum' => $custnum,
1645     'pkgpart' => $p->{'pkgpart'},
1646   } );
1647   my $error = $cust_pkg->check;
1648   return { 'error' => $error } if $error;
1649
1650   my @svc = ();
1651   unless ( $p->{'svcpart'} eq 'none' ) {
1652
1653     my $svcdb;
1654     my $svcpart = '';
1655     if ( $p->{'svcpart'} =~ /^(\d+)$/ ) {
1656       $svcpart = $1;
1657       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1658       return { 'error' => "Unknown svcpart $svcpart" } unless $part_svc;
1659       $svcdb = $part_svc->svcdb;
1660     } else {
1661       $svcdb = 'svc_acct';
1662     }
1663     $svcpart ||= $cust_pkg->part_pkg->svcpart($svcdb);
1664
1665     my %fields = (
1666       'svc_acct'     => [ qw( username domsvc _password sec_phrase popnum ) ],
1667       'svc_domain'   => [ qw( domain ) ],
1668       'svc_phone'    => [ qw( phonenum pin sip_password phone_name ) ],
1669       'svc_external' => [ qw( id title ) ],
1670       'svc_pbx'      => [ qw( id title ) ],
1671     );
1672   
1673     my $svc_x = "FS::$svcdb"->new( {
1674       'svcpart'   => $svcpart,
1675       map { $_ => $p->{$_} } @{$fields{$svcdb}}
1676     } );
1677     
1678     if ( $svcdb eq 'svc_acct' && exists($p->{"snarf_machine1"}) ) {
1679       my @acct_snarf;
1680       my $snarfnum = 1;
1681       while ( length($p->{"snarf_machine$snarfnum"}) ) {
1682         my $acct_snarf = new FS::acct_snarf ( {
1683           'machine'   => $p->{"snarf_machine$snarfnum"},
1684           'protocol'  => $p->{"snarf_protocol$snarfnum"},
1685           'username'  => $p->{"snarf_username$snarfnum"},
1686           '_password' => $p->{"snarf_password$snarfnum"},
1687         } );
1688         $snarfnum++;
1689         push @acct_snarf, $acct_snarf;
1690       }
1691       $svc_x->child_objects( \@acct_snarf );
1692     }
1693     
1694     my $y = $svc_x->setdefault; # arguably should be in new method
1695     return { 'error' => $y } if $y && !ref($y);
1696   
1697     $error = $svc_x->check;
1698     return { 'error' => $error } if $error;
1699
1700     push @svc, $svc_x;
1701
1702   }
1703
1704   use Tie::RefHash;
1705   tie my %hash, 'Tie::RefHash';
1706   %hash = ( $cust_pkg => \@svc );
1707   #msgcat
1708   $error = $cust_main->order_pkgs( \%hash, 'noexport' => 1 );
1709   return { 'error' => $error } if $error;
1710
1711   my $conf = new FS::Conf;
1712   if ( $conf->exists('signup_server-realtime') ) {
1713
1714     my $bill_error = _do_bop_realtime( $cust_main, $status );
1715
1716     if ($bill_error) {
1717       $cust_pkg->cancel('quiet'=>1);
1718       return $bill_error;
1719     } else {
1720       $cust_pkg->reexport;
1721     }
1722
1723   } else {
1724     $cust_pkg->reexport;
1725   }
1726
1727   my $svcnum = $svc[0] ? $svc[0]->svcnum : '';
1728
1729   return { error=>'', pkgnum=>$cust_pkg->pkgnum, svcnum=>$svcnum };
1730
1731 }
1732
1733 sub change_pkg {
1734   my $p = shift;
1735
1736   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1737   return { 'error' => $session } if $context eq 'error';
1738
1739   my $search = { 'custnum' => $custnum };
1740   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1741   my $cust_main = qsearchs('cust_main', $search )
1742     or return { 'error' => "unknown custnum $custnum" };
1743
1744   my $status = $cust_main->status;
1745   my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $p->{pkgnum} } )
1746     or return { 'error' => "unknown package $p->{pkgnum}" };
1747
1748   my @newpkg;
1749   my $error = FS::cust_pkg::order( $custnum,
1750                                    [$p->{pkgpart}],
1751                                    [$p->{pkgnum}],
1752                                    \@newpkg,
1753                                  );
1754
1755   my $conf = new FS::Conf;
1756   if ( $conf->exists('signup_server-realtime') ) {
1757
1758     my $bill_error = _do_bop_realtime( $cust_main, $status );
1759
1760     if ($bill_error) {
1761       $newpkg[0]->suspend;
1762       return $bill_error;
1763     } else {
1764       $newpkg[0]->reexport;
1765     }
1766
1767   } else {  
1768     $newpkg[0]->reexport;
1769   }
1770
1771   return { error => '', pkgnum => $cust_pkg->pkgnum };
1772
1773 }
1774
1775 sub order_recharge {
1776   my $p = shift;
1777
1778   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1779   return { 'error' => $session } if $context eq 'error';
1780
1781   my $search = { 'custnum' => $custnum };
1782   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1783   my $cust_main = qsearchs('cust_main', $search )
1784     or return { 'error' => "unknown custnum $custnum" };
1785
1786   my $status = $cust_main->status;
1787   my $cust_svc = qsearchs( 'cust_svc', { 'svcnum' => $p->{'svcnum'} } )
1788     or return { 'error' => "unknown service " . $p->{'svcnum'} };
1789
1790   my $svc_x = $cust_svc->svc_x;
1791   my $part_pkg = $cust_svc->cust_pkg->part_pkg;
1792
1793   my %vhash =
1794     map { $_ =~ /^recharge_(.*)$/; $1, $part_pkg->option($_, 1) } 
1795     qw ( recharge_seconds recharge_upbytes recharge_downbytes
1796          recharge_totalbytes );
1797   my $amount = $part_pkg->option('recharge_amount', 1); 
1798   
1799   my ($l, $v, $d) = $cust_svc->label;  # blah
1800   my $pkg = "Recharge $v"; 
1801
1802   my $bill_error = $cust_main->charge($amount, $pkg,
1803      "time: $vhash{seconds}, up: $vhash{upbytes}," . 
1804      "down: $vhash{downbytes}, total: $vhash{totalbytes}",
1805      $part_pkg->taxclass); #meh
1806
1807   my $conf = new FS::Conf;
1808   if ( $conf->exists('signup_server-realtime') && !$bill_error ) {
1809
1810     $bill_error = _do_bop_realtime( $cust_main, $status );
1811
1812     if ($bill_error) {
1813       return $bill_error;
1814     } else {
1815       my $error = $svc_x->recharge (\%vhash);
1816       return { 'error' => $error } if $error;
1817     }
1818
1819   } else {  
1820     my $error = $bill_error;
1821     $error ||= $svc_x->recharge (\%vhash);
1822     return { 'error' => $error } if $error;
1823   }
1824
1825   return { error => '', svc => $cust_svc->part_svc->svc };
1826
1827 }
1828
1829 sub _do_bop_realtime {
1830   my ($cust_main, $status) = (shift, shift);
1831
1832     my $old_balance = $cust_main->balance;
1833
1834     my $bill_error =    $cust_main->bill
1835                      || $cust_main->apply_payments_and_credits
1836                      || $cust_main->realtime_collect('selfservice' => 1);
1837
1838     if (    $cust_main->balance > $old_balance
1839          && $cust_main->balance > 0
1840          && ( $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/ ?
1841               1 : $status eq 'suspended' ) ) {
1842       #this makes sense.  credit is "un-doing" the invoice
1843       my $conf = new FS::Conf;
1844       $cust_main->credit( sprintf("%.2f", $cust_main->balance - $old_balance ),
1845                           'self-service decline',
1846                           'reason_type' => $conf->config('signup_credit_type'),
1847                         );
1848       $cust_main->apply_credits( 'order' => 'newest' );
1849
1850       return { 'error' => '_decline', 'bill_error' => $bill_error };
1851     }
1852
1853     '';
1854 }
1855
1856 sub renew_info {
1857   my $p = shift;
1858
1859   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1860   return { 'error' => $session } if $context eq 'error';
1861
1862   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1863     or return { 'error' => "unknown custnum $custnum" };
1864
1865   my @cust_pkg = sort { $a->bill <=> $b->bill }
1866                  grep { $_->part_pkg->freq ne '0' }
1867                  $cust_main->ncancelled_pkgs;
1868
1869   #return { 'error' => 'No active packages to renew.' } unless @cust_pkg;
1870
1871   my $total = $cust_main->balance;
1872
1873   my @array = map {
1874                     my $bill = $_->bill;
1875                     $total += $_->part_pkg->base_recur($_, \$bill);
1876                     my $renew_date = $_->part_pkg->add_freq($_->bill);
1877                     {
1878                       'pkgnum'             => $_->pkgnum,
1879                       'amount'             => sprintf('%.2f', $total),
1880                       'bill_date'          => $_->bill,
1881                       'bill_date_pretty'   => time2str('%x', $_->bill),
1882                       'renew_date'         => $renew_date,
1883                       'renew_date_pretty'  => time2str('%x', $renew_date),
1884                       'expire_date'        => $_->expire,
1885                       'expire_date_pretty' => time2str('%x', $_->expire),
1886                     };
1887                   }
1888                   @cust_pkg;
1889
1890   return { 'dates' => \@array };
1891
1892 }
1893
1894 sub payment_info_renew_info {
1895   my $p = shift;
1896   my $renew_info   = renew_info($p);
1897   my $payment_info = payment_info($p);
1898   return { %$renew_info,
1899            %$payment_info,
1900          };
1901 }
1902
1903 sub order_renew {
1904   my $p = shift;
1905
1906   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1907   return { 'error' => $session } if $context eq 'error';
1908
1909   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1910     or return { 'error' => "unknown custnum $custnum" };
1911
1912   my $date = $p->{'date'};
1913
1914   my $now = time;
1915
1916   #freeside-daily -n -d $date fs_daily $custnum
1917   $cust_main->bill_and_collect( 'time'         => $date,
1918                                 'invoice_time' => $now,
1919                                 'actual_time'  => $now,
1920                                 'check_freq'   => '1d',
1921                               );
1922
1923   return { 'error' => '' };
1924
1925 }
1926
1927 sub suspend_pkg {
1928   my $p = shift;
1929   my $session = _cache->get($p->{'session_id'})
1930     or return { 'error' => "Can't resume session" }; #better error message
1931
1932   my $custnum = $session->{'custnum'};
1933
1934   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1935     or return { 'error' => "unknown custnum $custnum" };
1936
1937   my $conf = new FS::Conf;
1938   my $reasonnum = 
1939     $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum)
1940       or return { 'error' => 'Permission denied' };
1941
1942   my $pkgnum = $p->{'pkgnum'};
1943
1944   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
1945                                         'pkgnum'  => $pkgnum,   } )
1946     or return { 'error' => "unknown pkgnum $pkgnum" };
1947
1948   my $error = $cust_pkg->suspend(reason => $reasonnum);
1949   return { 'error' => $error };
1950
1951 }
1952
1953 sub cancel_pkg {
1954   my $p = shift;
1955   my $session = _cache->get($p->{'session_id'})
1956     or return { 'error' => "Can't resume session" }; #better error message
1957
1958   my $custnum = $session->{'custnum'};
1959
1960   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1961     or return { 'error' => "unknown custnum $custnum" };
1962
1963   my $pkgnum = $p->{'pkgnum'};
1964
1965   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
1966                                         'pkgnum'  => $pkgnum,   } )
1967     or return { 'error' => "unknown pkgnum $pkgnum" };
1968
1969   my $error = $cust_pkg->cancel('quiet' => 1);
1970   return { 'error' => $error };
1971
1972 }
1973
1974 sub provision_phone {
1975  my $p = shift;
1976  my @bulkdid;
1977  @bulkdid = @{$p->{'bulkdid'}} if $p->{'bulkdid'};
1978
1979  if($p->{'svcnum'} && $p->{'svcnum'} =~ /^\d+$/){
1980       my($context, $session, $custnum) = _custoragent_session_custnum($p);
1981       return { 'error' => $session } if $context eq 'error';
1982     
1983       my $svc_phone = qsearchs('svc_phone', { svcnum => $p->{'svcnum'} });
1984       return { 'error' => 'service not found' } unless $svc_phone;
1985       return { 'error' => 'invalid svcnum' } 
1986         if $svc_phone && $svc_phone->cust_svc->cust_pkg->custnum != $custnum;
1987
1988       $svc_phone->email($p->{'email'}) 
1989         if $svc_phone->email ne $p->{'email'} && $p->{'email'} =~ /^([\w\.\d@]+|)$/;
1990       $svc_phone->forwarddst($p->{'forwarddst'}) 
1991         if $svc_phone->forwarddst ne $p->{'forwarddst'} 
1992             && $p->{'forwarddst'} =~ /^(\d+|)$/;
1993       return { 'error' => $svc_phone->replace };
1994  }
1995
1996 # single DID LNP
1997  unless($p->{'lnp'}) {
1998     $p->{'lnp_desired_due_date'} = parse_datetime($p->{'lnp_desired_due_date'});
1999     $p->{'lnp_status'} = "portingin";
2000     return _provision( 'FS::svc_phone',
2001                   [qw(lnp_desired_due_date lnp_other_provider 
2002                     lnp_other_provider_account phonenum countrycode lnp_status)],
2003                   [qw(phonenum countrycode)],
2004                   $p,
2005                   @_
2006                 );
2007  }
2008
2009 # single DID order
2010  unless (scalar(@bulkdid)) {
2011     return _provision( 'FS::svc_phone',
2012                   [qw(phonenum countrycode)],
2013                   [qw(phonenum countrycode)],
2014                   $p,
2015                   @_
2016                 );
2017  }
2018
2019 # bulk DID order case
2020   my $error;
2021   foreach my $did ( @bulkdid ) {
2022     $did =~ s/[^0-9]//g;
2023     $error = _provision( 'FS::svc_phone',
2024               [qw(phonenum countrycode)],
2025               [qw(phonenum countrycode)],
2026               {
2027                 'pkgnum' => $p->{'pkgnum'},
2028                 'svcpart' => $p->{'svcpart'},
2029                 'phonenum' => $did,
2030                 'countrycode' => $p->{'countrycode'},
2031                 'session_id' => $p->{'session_id'},
2032               }
2033             );
2034     return $error if ($error->{'error'} && length($error->{'error'}) > 1);
2035   }
2036   { 'bulkdid' => [ @bulkdid ], 'svc' => $error->{'svc'} }
2037 }
2038
2039 sub provision_acct {
2040   my $p = shift;
2041   warn "provision_acct called\n"
2042     if $DEBUG;
2043
2044   return { 'error' => gettext('passwords_dont_match') }
2045     if $p->{'_password'} ne $p->{'_password2'};
2046   return { 'error' => gettext('empty_password') }
2047     unless length($p->{'_password'});
2048  
2049   if ($p->{'domsvc'}) {
2050     my %domains = domain_select_hash FS::svc_acct(map { $_ => $p->{$_} }
2051                                                   qw ( svcpart pkgnum ) );
2052     return { 'error' => gettext('invalid_domain') }
2053       unless ($domains{$p->{'domsvc'}});
2054   }
2055
2056   warn "provision_acct calling _provision\n"
2057     if $DEBUG;
2058   _provision( 'FS::svc_acct',
2059               [qw(username _password domsvc)],
2060               [qw(username _password domsvc)],
2061               $p,
2062               @_
2063             );
2064 }
2065
2066 sub provision_external {
2067   my $p = shift;
2068   #_provision( 'FS::svc_external', [qw(id title)], [qw(id title)], $p, @_ );
2069   _provision( 'FS::svc_external',
2070               [],
2071               [qw(id title)],
2072               $p,
2073               @_
2074             );
2075 }
2076
2077 sub _provision {
2078   my( $class, $fields, $return_fields, $p ) = splice(@_, 0, 4);
2079   warn "_provision called for $class\n"
2080     if $DEBUG;
2081
2082   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2083   return { 'error' => $session } if $context eq 'error';
2084
2085   my $search = { 'custnum' => $custnum };
2086   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2087   my $cust_main = qsearchs('cust_main', $search )
2088     or return { 'error' => "unknown custnum $custnum" };
2089
2090   my $pkgnum = $p->{'pkgnum'};
2091
2092   warn "searching for custnum $custnum pkgnum $pkgnum\n"
2093     if $DEBUG;
2094   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
2095                                         'pkgnum'  => $pkgnum,
2096                                                                } )
2097     or return { 'error' => "unknown pkgnum $pkgnum" };
2098
2099   warn "searching for svcpart ". $p->{'svcpart'}. "\n"
2100     if $DEBUG;
2101   my $part_svc = qsearchs('part_svc', { 'svcpart' => $p->{'svcpart'} } )
2102     or return { 'error' => "unknown svcpart $p->{'svcpart'}" };
2103
2104   warn "creating $class record\n"
2105     if $DEBUG;
2106   my $svc_x = $class->new( {
2107     'pkgnum'  => $p->{'pkgnum'},
2108     'svcpart' => $p->{'svcpart'},
2109     map { $_ => $p->{$_} } @$fields
2110   } );
2111   warn "inserting $class record\n"
2112     if $DEBUG;
2113   my $error = $svc_x->insert;
2114
2115   unless ( $error ) {
2116     warn "finding inserted record for svcnum ". $svc_x->svcnum. "\n"
2117       if $DEBUG;
2118     $svc_x = qsearchs($svc_x->table, { 'svcnum' => $svc_x->svcnum })
2119   }
2120
2121   my $return = { 'svc'   => $part_svc->svc,
2122                  'error' => $error,
2123                  map { $_ => $svc_x->get($_) } @$return_fields
2124                };
2125   warn "_provision returning ". Dumper($return). "\n"
2126     if $DEBUG;
2127   return $return;
2128
2129 }
2130
2131 sub part_svc_info {
2132   my $p = shift;
2133
2134   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2135   return { 'error' => $session } if $context eq 'error';
2136
2137   my $search = { 'custnum' => $custnum };
2138   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2139   my $cust_main = qsearchs('cust_main', $search )
2140     or return { 'error' => "unknown custnum $custnum" };
2141
2142   my $pkgnum = $p->{'pkgnum'};
2143
2144   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
2145                                         'pkgnum'  => $pkgnum,
2146                                                                } )
2147     or return { 'error' => "unknown pkgnum $pkgnum" };
2148
2149   my $svcpart = $p->{'svcpart'};
2150
2151   my $pkg_svc = qsearchs('pkg_svc', { 'pkgpart' => $cust_pkg->pkgpart,
2152                                       'svcpart' => $svcpart,           } )
2153     or return { 'error' => "unknown svcpart $svcpart for pkgnum $pkgnum" };
2154   my $part_svc = $pkg_svc->part_svc;
2155
2156   my $conf = new FS::Conf;
2157
2158   my $ret = {
2159     'svc'     => $part_svc->svc,
2160     'svcdb'   => $part_svc->svcdb,
2161     'pkgnum'  => $pkgnum,
2162     'svcpart' => $svcpart,
2163     'custnum' => $custnum,
2164
2165     'security_phrase' => 0, #XXX !
2166     'svc_acct_pop'    => [], #XXX !
2167     'popnum'          => '',
2168     'init_popstate'   => '',
2169     'popac'           => '',
2170     'acstate'         => '',
2171
2172     'small_custview' =>
2173       small_custview( $cust_main, $conf->config('countrydefault') ),
2174
2175   };
2176
2177   if ($p->{'svcnum'} && $p->{'svcnum'} =~ /^\d+$/ 
2178                      && $ret->{'svcdb'} eq 'svc_phone') {
2179         $ret->{'svcnum'} = $p->{'svcnum'};
2180         my $svc_phone = qsearchs('svc_phone', { svcnum => $p->{'svcnum'} });
2181         if ( $svc_phone && $svc_phone->cust_svc->cust_pkg->custnum == $custnum ) {
2182             $ret->{'email'} = $svc_phone->email;
2183             $ret->{'forwarddst'} = $svc_phone->forwarddst;
2184         }
2185   }
2186
2187   $ret;
2188 }
2189
2190 sub unprovision_svc {
2191   my $p = shift;
2192
2193   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2194   return { 'error' => $session } if $context eq 'error';
2195
2196   my $search = { 'custnum' => $custnum };
2197   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2198   my $cust_main = qsearchs('cust_main', $search )
2199     or return { 'error' => "unknown custnum $custnum" };
2200
2201   my $svcnum = $p->{'svcnum'};
2202
2203   my $cust_svc = qsearchs('cust_svc', { 'svcnum'  => $svcnum, } )
2204     or return { 'error' => "unknown svcnum $svcnum" };
2205
2206   return { 'error' => "Service $svcnum does not belong to customer $custnum" }
2207     unless $cust_svc->cust_pkg->custnum == $custnum;
2208
2209   my $conf = new FS::Conf;
2210
2211   return { 'svc'   => $cust_svc->part_svc->svc,
2212            'error' => $cust_svc->cancel,
2213            'small_custview' =>
2214              small_custview( $cust_main, $conf->config('countrydefault') ),
2215          };
2216
2217 }
2218
2219 sub myaccount_passwd {
2220   my $p = shift;
2221   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2222   return { 'error' => $session } if $context eq 'error';
2223
2224   return { 'error' => "New passwords don't match." }
2225     if $p->{'new_password'} ne $p->{'new_password2'};
2226
2227   return { 'error' => 'Enter new password' }
2228     unless length($p->{'new_password'});
2229
2230   #my $search = { 'custnum' => $custnum };
2231   #$search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2232   $custnum =~ /^(\d+)$/ or die "illegal custnum";
2233   my $search = " AND custnum = $1";
2234   $search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent';
2235
2236   my $svc_acct = qsearchs( {
2237     'table'     => 'svc_acct',
2238     'addl_from' => 'LEFT JOIN cust_svc  USING ( svcnum  ) '.
2239                    'LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
2240                    'LEFT JOIN cust_main USING ( custnum ) ',
2241     'hashref'   => { 'svcnum' => $p->{'svcnum'}, },
2242     'extra_sql' => $search, #important
2243   } )
2244     or return { 'error' => "Service not found" };
2245
2246   if ( exists($p->{'old_password'}) ) {
2247     return { 'error' => "Incorrect password." }
2248       unless $svc_acct->check_password($p->{'old_password'});
2249   }
2250
2251   $svc_acct->_password($p->{'new_password'});
2252   my $error = $svc_acct->replace();
2253
2254   my($label, $value) = $svc_acct->cust_svc->label;
2255
2256   return { 'error' => $error,
2257            'label' => $label,
2258            'value' => $value,
2259          };
2260
2261 }
2262
2263 sub create_ticket {
2264   my $p = shift;
2265   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2266   return { 'error' => $session } if $context eq 'error';
2267
2268   warn "$me create_ticket: initializing ticket system\n" if $DEBUG;
2269   FS::TicketSystem->init();
2270
2271   my $conf = new FS::Conf;
2272   my $queue = $p->{'queue'}
2273               || $conf->config('ticket_system-selfservice_queueid')
2274               || $conf->config('ticket_system-default_queueid');
2275
2276   warn "$me create_ticket: creating ticket\n" if $DEBUG;
2277   my $err_or_ticket = FS::TicketSystem->create_ticket(
2278     '', #create RT session based on FS CurrentUser (fs_selfservice)
2279     'queue'   => $queue,
2280     'custnum' => $custnum,
2281     'svcnum'  => $session->{'svcnum'},
2282     map { $_ => $p->{$_} } qw( requestor cc subject message mime_type )
2283   );
2284
2285   if ( ref($err_or_ticket) ) {
2286     warn "$me create_ticket: successful: ". $err_or_ticket->id. "\n"
2287       if $DEBUG;
2288     return { 'error'     => '',
2289              'ticket_id' => $err_or_ticket->id,
2290            };
2291   } else {
2292     warn "$me create_ticket: unsuccessful: $err_or_ticket\n"
2293       if $DEBUG;
2294     return { 'error' => $err_or_ticket };
2295   }
2296
2297
2298 }
2299
2300 sub did_report {
2301   my $p = shift;
2302   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2303   return { 'error' => $session } if $context eq 'error';
2304  
2305   return { error => 'requested format not implemented' } 
2306     unless ($p->{'format'} eq 'csv' || $p->{'format'} eq 'xls');
2307
2308   my $conf = new FS::Conf;
2309   my $age_threshold = 0;
2310   $age_threshold = time() - $conf->config('selfservice-recent-did-age')
2311     if ($p->{'recentonly'} && $conf->exists('selfservice-recent-did-age'));
2312
2313   my $search = { 'custnum' => $custnum };
2314   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2315   my $cust_main = qsearchs('cust_main', $search )
2316     or return { 'error' => "unknown custnum $custnum" };
2317
2318 # does it make more sense to just run one sql query for this instead of all the
2319 # insanity below? would increase performance greately for large data sets?
2320   my @svc_phone = ();
2321   foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) {
2322         my @part_svc = $cust_pkg->part_svc;
2323         foreach my $part_svc ( @part_svc ) {
2324             if($part_svc->svcdb eq 'svc_phone'){
2325                 my @cust_pkg_svc = @{$part_svc->cust_pkg_svc};
2326                 foreach my $cust_pkg_svc ( @cust_pkg_svc ) {
2327                     push @svc_phone, $cust_pkg_svc->svc_x
2328                         if $cust_pkg_svc->date_inserted >= $age_threshold;
2329                 }
2330             }
2331         }
2332   }
2333
2334   my $csv;
2335   my $xls;
2336   my($xls_r,$xls_c) = (0,0);
2337   my $xls_workbook;
2338   my $content = '';
2339   my @fields = qw( countrycode phonenum pin sip_password phone_name );
2340   if($p->{'format'} eq 'csv') {
2341     $csv = new Text::CSV_XS { 'always_quote' => 1,
2342                                  'eol'          => "\n",
2343                                 };
2344     return { 'error' => 'Unable to create CSV' } unless $csv->combine(@fields);
2345     $content .= $csv->string;
2346   }
2347   elsif($p->{'format'} eq 'xls') {
2348     my $XLS1 = new IO::Scalar \$content;
2349     $xls_workbook = Spreadsheet::WriteExcel->new($XLS1) 
2350         or return { 'error' => "Error opening .xls file: $!" };
2351     $xls = $xls_workbook->add_worksheet('DIDs');
2352     foreach ( @fields ) {
2353         $xls->write(0,$xls_c++,$_);
2354     }
2355     $xls_r++;
2356   }
2357
2358   foreach my $svc_phone ( @svc_phone ) {
2359     my @cols = map { $svc_phone->$_ } @fields;
2360     if($p->{'format'} eq 'csv') {
2361         return { 'error' => 'Unable to create CSV' } 
2362             unless $csv->combine(@cols);
2363         $content .= $csv->string;
2364     }
2365     elsif($p->{'format'} eq 'xls') {
2366         $xls_c = 0;
2367         foreach ( @cols ) {
2368             $xls->write($xls_r,$xls_c++,$_);
2369         }
2370         $xls_r++;
2371     }
2372   }
2373
2374   $xls_workbook->close() if $p->{'format'} eq 'xls';
2375   
2376   { content => $content, format => $p->{'format'}, };
2377 }
2378
2379 sub get_ticket {
2380   my $p = shift;
2381   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2382   return { 'error' => $session } if $context eq 'error';
2383
2384   warn "$me get_ticket: initializing ticket system\n" if $DEBUG;
2385   FS::TicketSystem->init();
2386   return { 'error' => 'get_ticket configuration error' }
2387     if $FS::TicketSystem::system ne 'RT_Internal';
2388
2389   # check existence and ownership as part of this
2390   warn "$me get_ticket: fetching ticket\n" if $DEBUG;
2391   my $rt_session = FS::TicketSystem->session('');
2392   my $Ticket = FS::TicketSystem->get_ticket_object(
2393     $rt_session, 
2394     ticket_id => $p->{'ticket_id'},
2395     custnum => $custnum
2396   );
2397   return { 'error' => 'ticket not found' } if !$Ticket;
2398
2399   if ( length( $p->{'subject'} || '' ) ) {
2400     # subject change
2401     if ( $p->{'subject'} ne $Ticket->Subject ) {
2402       my ($val, $msg) = $Ticket->SetSubject($p->{'subject'});
2403       return { 'error' => "unable to set subject: $msg" } if !$val;
2404     }
2405   }
2406
2407   if(length($p->{'reply'})) {
2408     my @err_or_res = FS::TicketSystem->correspond_ticket(
2409       $rt_session,
2410       'ticket_id' => $p->{'ticket_id'},
2411       'content' => $p->{'reply'},
2412     );
2413
2414     return { 'error' => 'unable to reply to ticket' } 
2415     unless ( $err_or_res[0] != 0 && defined $err_or_res[2] );
2416   }
2417
2418   warn "$me get_ticket: getting ticket history\n" if $DEBUG;
2419   my $err_or_ticket = FS::TicketSystem->get_ticket(
2420     $rt_session,
2421     'ticket_id' => $p->{'ticket_id'},
2422   );
2423
2424   if ( !ref($err_or_ticket) ) { # there is no way this should ever happen
2425     warn "$me get_ticket: unsuccessful: $err_or_ticket\n"
2426       if $DEBUG;
2427     return { 'error' => $err_or_ticket };
2428   }
2429
2430   my @custs = @{$err_or_ticket->{'custs'}};
2431   my @txns = @{$err_or_ticket->{'txns'}};
2432   my @filtered_txns;
2433
2434   # superseded by check in get_ticket_object
2435   #return { 'error' => 'invalid ticket requested' } 
2436   #unless grep($_ eq $custnum, @custs);
2437
2438   foreach my $txn ( @txns ) {
2439     push @filtered_txns, $txn 
2440     if ($txn->{'type'} eq 'EmailRecord' 
2441       || $txn->{'type'} eq 'Correspond'
2442       || $txn->{'type'} eq 'Create');
2443   }
2444
2445   warn "$me get_ticket: successful: \n"
2446   if $DEBUG;
2447   return { 'error'     => '',
2448     'transactions' => \@filtered_txns,
2449     'ticket_fields' => $err_or_ticket->{'fields'},
2450     'ticket_id' => $p->{'ticket_id'},
2451   };
2452 }
2453
2454 sub adjust_ticket_priority {
2455   my $p = shift;
2456   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2457   return { 'error' => $session } if $context eq 'error';
2458
2459   warn "$me adjust_ticket_priority: initializing ticket system\n" if $DEBUG;
2460   FS::TicketSystem->init;
2461   my $ss_priority = FS::TicketSystem->selfservice_priority;
2462
2463   return { 'error' => 'adjust_ticket_priority configuration error' }
2464     if $FS::TicketSystem::system ne 'RT_Internal'
2465       or !$ss_priority;
2466
2467   my $values = $p->{'values'}; #hashref, id => priority value
2468   my %ticket_error;
2469
2470   foreach my $id (keys %$values) {
2471     warn "$me adjust_ticket_priority: fetching ticket $id\n" if $DEBUG;
2472     my $Ticket = FS::TicketSystem->get_ticket_object('',
2473       'ticket_id' => $id,
2474       'custnum'   => $custnum,
2475     );
2476     if ( !$Ticket ) {
2477       $ticket_error{$id} = 'ticket not found';
2478       next;
2479     }
2480     
2481   # RT API stuff--would we gain anything by wrapping this in FS::TicketSystem?
2482   # We're not going to implement it for RT_External.
2483     my $old_value = $Ticket->FirstCustomFieldValue($ss_priority);
2484     my $new_value = $values->{$id};
2485     next if $old_value eq $new_value;
2486
2487     warn "$me adjust_ticket_priority: updating ticket $id\n" if $DEBUG;
2488
2489     # AddCustomFieldValue works fine (replacing any existing value) if it's 
2490     # a single-valued custom field, which it should be.  If it's not, you're 
2491     # doing something wrong.
2492     my ($val, $msg);
2493     if ( length($new_value) ) {
2494       ($val, $msg) = $Ticket->AddCustomFieldValue( 
2495         Field => $ss_priority,
2496         Value => $new_value,
2497       );
2498     }
2499     else {
2500       ($val, $msg) = $Ticket->DeleteCustomFieldValue(
2501         Field => $ss_priority,
2502         Value => $old_value,
2503       );
2504     }
2505
2506     $ticket_error{$id} = $msg if !$val;
2507     warn "$me adjust_ticket_priority: $id: $msg\n" if $DEBUG and !$val;
2508   }
2509   return { 'error' => '',
2510            'ticket_error' => \%ticket_error,
2511            %{ customer_info($p) } # send updated customer info back
2512          }
2513 }
2514
2515 #--
2516
2517 sub _custoragent_session_custnum {
2518   my $p = shift;
2519
2520   my($context, $session, $custnum);
2521   if ( $p->{'session_id'} ) {
2522
2523     $context = 'customer';
2524     $session = _cache->get($p->{'session_id'})
2525       or return ( 'error' => "Can't resume session" ); #better error message
2526     $custnum = $session->{'custnum'};
2527
2528   } elsif ( $p->{'agent_session_id'} ) {
2529
2530     $context = 'agent';
2531     my $agent_cache = new FS::ClientAPI_SessionCache( {
2532       'namespace' => 'FS::ClientAPI::Agent',
2533     } );
2534     $session = $agent_cache->get($p->{'agent_session_id'})
2535       or return ( 'error' => "Can't resume session" ); #better error message
2536     $custnum = $p->{'custnum'};
2537
2538   } else {
2539     $context = 'error';
2540     return ( 'error' => "Can't resume session" ); #better error message
2541   }
2542
2543   ($context, $session, $custnum);
2544
2545 }
2546
2547 1;
2548