Merge branch 'master' of git.freeside.biz:/home/git/freeside
[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 IO::Scalar;
8 use Data::Dumper;
9 use Digest::MD5 qw(md5_hex);
10 use Digest::SHA qw(sha512_hex);
11 use Date::Format;
12 use Time::Duration;
13 use Time::Local qw(timelocal_nocheck);
14 use Business::CreditCard;
15 use HTML::Entities;
16 use Text::CSV_XS;
17 use Spreadsheet::WriteExcel;
18 use OLE::Storage_Lite;
19 use FS::UI::Web::small_custview qw(small_custview); #less doh
20 use FS::UI::Web;
21 use FS::UI::bytecount qw( display_bytecount );
22 use FS::Conf;
23 #use FS::UID qw(dbh);
24 use FS::Record qw(qsearch qsearchs dbh);
25 use FS::Msgcat qw(gettext);
26 use FS::Misc qw(card_types money_pretty);
27 use FS::Misc::DateTime qw(parse_datetime);
28 use FS::TicketSystem;
29 use FS::ClientAPI_SessionCache;
30 use FS::cust_svc;
31 use FS::svc_acct;
32 use FS::svc_forward;
33 use FS::svc_domain;
34 use FS::svc_phone;
35 use FS::svc_external;
36 use FS::svc_dsl;
37 use FS::dsl_device;
38 use FS::part_svc;
39 use FS::cust_main;
40 use FS::cust_bill;
41 use FS::legacy_cust_bill;
42 use FS::cust_main_county;
43 use FS::part_pkg;
44 use FS::cust_pkg;
45 use FS::payby;
46 use FS::acct_rt_transaction;
47 use FS::msg_template;
48 use FS::contact;
49 use FS::cust_contact;
50 use FS::cust_location;
51 use FS::cust_payby;
52
53 # for code organization
54 use FS::ClientAPI::MyAccount::contact;
55 use FS::ClientAPI::MyAccount::quotation;
56
57 $DEBUG = 0;
58 $me = '[FS::ClientAPI::MyAccount]';
59
60 use vars qw( @cust_main_editable_fields @location_editable_fields );
61 @cust_main_editable_fields = qw(
62   first last company daytime night fax mobile
63   locale
64   payby payinfo payname paystart_month paystart_year payissue payip
65   ss paytype paystate stateid stateid_state
66 );
67 @location_editable_fields = qw(
68   address1 address2 city county state zip country
69 );
70
71
72 BEGIN { #preload to reduce time customer_info takes
73   if ( $FS::TicketSystem::system ) {
74     warn "$me: initializing ticket system\n" if $DEBUG;
75     FS::TicketSystem->init();
76   }
77 }
78
79 sub _cache {
80   $cache ||= new FS::ClientAPI_SessionCache( {
81                'namespace' => 'FS::ClientAPI::MyAccount',
82              } );
83 }
84
85 sub skin_info {
86   my $p = shift;
87
88   my($context, $session, $custnum) = _custoragent_session_custnum($p);
89   #return { 'error' => $session } if $context eq 'error';
90
91   my $agentnum = '';
92   if ( $context eq 'customer' && $custnum ) {
93
94     my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?')
95       or die dbh->errstr;
96
97     $sth->execute($custnum) or die $sth->errstr;
98
99     $agentnum = $sth->fetchrow_arrayref->[0]
100       or die "no agentnum for custnum $custnum";
101
102   #} elsif ( $context eq 'agent' ) {
103   } elsif ( defined($p->{'agentnum'}) and $p->{'agentnum'} =~ /^(\d+)$/ ) {
104     $agentnum = $1;
105   }
106   $p->{'agentnum'} = $agentnum;
107
108   my $conf = new FS::Conf;
109
110   #false laziness w/Signup.pm
111
112   my $skin_info_cache_agent = _cache->get("skin_info_cache_agent$agentnum");
113
114   if ( $skin_info_cache_agent ) {
115
116     warn "$me loading cached skin info for agentnum $agentnum\n"
117       if $DEBUG > 1;
118
119   } else {
120
121     warn "$me populating skin info cache for agentnum $agentnum\n"
122       if $DEBUG > 1;
123
124     $skin_info_cache_agent = {
125       'agentnum' => $agentnum,
126       ( map { $_ => scalar( $conf->config($_, $agentnum) ) }
127         qw( company_name date_format ) ),
128       ( map { $_ => scalar( $conf->config("selfservice-$_", $agentnum ) ) }
129         qw( body_bgcolor box_bgcolor stripe1_bgcolor stripe2_bgcolor
130             text_color link_color vlink_color hlink_color alink_color
131             font title_color title_align title_size menu_bgcolor menu_fontsize
132           )
133       ),
134       'menu_disable' => [ $conf->config('selfservice-menu_disable',$agentnum) ],
135       ( map { $_ => $conf->exists("selfservice-$_", $agentnum ) }
136         qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo enable_payment_without_balance )
137       ),
138       ( map { $_ => scalar($conf->config_binary("selfservice-$_", $agentnum)) }
139         qw( title_left_image title_right_image
140             menu_top_image menu_body_image menu_bottom_image
141           )
142       ),
143       'logo' => scalar($conf->config_binary('logo.png', $agentnum )),
144       ( map { $_ => join("\n", $conf->config("selfservice-$_", $agentnum ) ) }
145         qw( head body_header body_footer company_address ) ),
146       'money_char' => $conf->config("money_char") || '$',
147       'menu' => join("\n", $conf->config("ng_selfservice-menu", $agentnum ) ) ||
148                 'main.php Home
149
150                  services.php Services
151                  services.php My Services
152                  services_new.php Order a new service
153
154                  personal.php Profile
155                  personal.php Personal Information
156                  password.php Change Password
157
158                  payment.php Payments
159                  payment_cc.php Credit Card Payment
160                  payment_ach.php Electronic Check Payment
161                  payment_paypal.php PayPal Payment
162                  payment_webpay.php Webpay Payments
163
164                  usage.php Usage
165                  usage_data.php Data usage
166                  usage_cdr.php Call usage
167
168                  tickets.php Help Desk
169                  tickets.php Open Tickets
170                  tickets_resolved.php Resolved Tickets
171                  ticket_create.php Create a new ticket
172
173                  docs.php FAQs
174
175                  logout.php Logout
176                 ',
177     };
178
179     _cache->set("skin_info_cache_agent$agentnum", $skin_info_cache_agent);
180
181   }
182
183   #{ %$skin_info_cache_agent };
184   $skin_info_cache_agent;
185
186 }
187
188 sub login_info {
189   my $p = shift;
190
191   my $conf = new FS::Conf;
192
193   my %info = (
194     %{ skin_info($p) },
195     'phone_login'  => $conf->exists('selfservice_server-phone_login'),
196     'single_domain'=> scalar($conf->config('selfservice_server-single_domain')),
197     'banner_url'       => scalar($conf->config('selfservice-login_banner_url')),
198     'banner_image_md5' => 
199       md5_hex($conf->config_binary('selfservice-login_banner_image')),
200   );
201
202   return \%info;
203
204 }
205
206 sub login_banner_image {
207   my $p = shift;
208   my $conf = new FS::Conf;
209   my $image = $conf->config_binary('selfservice-login_banner_image');
210   return { 
211     'md5'   => md5_hex($image),
212     'image' => $image,
213   };
214 }
215
216 #false laziness w/FS::ClientAPI::passwd::passwd
217 sub login {
218   my $p = shift;
219
220   my $conf = new FS::Conf;
221
222   my $svc_x = '';
223   my $session = {};
224   if ( $p->{'domain'} eq 'svc_phone'
225        && $conf->exists('selfservice_server-phone_login') ) { 
226
227     my $svc_phone = qsearchs( 'svc_phone', { 'phonenum' => $p->{'username'} } );
228     return { error => 'Number not found.' } unless $svc_phone;
229
230     #XXX?
231     #my $pkg_svc = $svc_acct->cust_svc->pkg_svc;
232     #return { error => 'Only primary user may log in.' } 
233     #  if $conf->exists('selfservice_server-primary_only')
234     #    && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' );
235
236     return { error => 'Incorrect PIN.' }
237       unless $svc_phone->check_pin($p->{'password'});
238
239     $svc_x = $svc_phone;
240
241   } elsif ( $p->{email}
242               && (my $contact = FS::contact->by_selfservice_email($p->{email}))
243           )
244   {
245     return { error => 'Incorrect contact password.' }
246       unless $contact->authenticate_password($p->{'password'});
247
248     $session->{'contactnum'} = $contact->contactnum;
249
250     my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
251     if ( scalar(@cust_contact) == 1 ) {
252       $session->{'custnum'} = $cust_contact[0]->custnum;
253     } elsif ( scalar(@cust_contact) ) {
254       $session->{'customers'} = { map { $_->custnum => $_->cust_main->name }
255                                     @cust_contact
256                                 };
257     } else {
258       return { error => 'No customer self-service access for contact' }; #??
259     }
260
261   } else {
262
263     ( $p->{username}, $p->{domain} ) = split('@', $p->{email}) if $p->{email};
264
265     my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
266       or return { error => 'Domain '. $p->{'domain'}. ' not found' };
267
268     my @svc_acct = qsearch( 'svc_acct', { 'username'  => $p->{'username'},
269                                           'domsvc'    => $svc_domain->svcnum, }
270                           );
271
272     if ( $conf->exists('selfservice_server-login_svcpart') ) {
273       my @svcpart = $conf->config('selfservice_server-login_svcpart');
274       @svc_acct = grep { my $svcpart = $_->cust_svc->svcpart;
275                          scalar( grep( $_ eq $svcpart, @svcpart ) );
276                        }
277                     @svc_acct;
278     }
279
280     if ( $conf->exists('selfservice_server-primary_only') ) {
281         @svc_acct =
282           grep {
283             my $cust_svc = $_->cust_svc;
284             $cust_svc->cust_pkg->part_pkg->svcpart([qw( svc_acct svc_phone )])
285               == $cust_svc->svcpart
286           }
287           @svc_acct;
288     }
289
290     return { error => 'User not found.' } unless @svc_acct;
291
292     return { error => 'Multiple users.' } if scalar(@svc_acct) > 1;
293
294     my $svc_acct = $svc_acct[0];
295
296     if ( $conf->exists('selfservice_server-login_svcpart') ) {
297       my @svcpart = $conf->config('selfservice_server-login_svcpart');
298       my $svcpart = $svc_acct->cust_svc->svcpart;
299       return { error => 'Invalid user.' } 
300         unless grep($_ eq $svcpart, @svcpart);
301     }
302
303     return { error => 'Incorrect password.' }
304       unless $svc_acct->check_password($p->{'password'});
305
306     $svc_x = $svc_acct;
307
308   }
309
310   if ( $svc_x ) {
311
312     $session->{'svcnum'} = $svc_x->svcnum;
313
314     my $cust_svc = $svc_x->cust_svc;
315     my $cust_pkg = $cust_svc->cust_pkg;
316     if ( $cust_pkg ) {
317       my $cust_main = $cust_pkg->cust_main;
318       $session->{'custnum'} = $cust_main->custnum;
319       if ( $conf->exists('pkg-balances') ) {
320         my @cust_pkg = grep { $_->part_pkg->freq !~ /^(0|$)/ }
321                             $cust_main->ncancelled_pkgs;
322         $session->{'pkgnum'} = $cust_pkg->pkgnum
323           if scalar(@cust_pkg) > 1;
324       }
325     }
326
327     #my $pkg_svc = $svc_acct->cust_svc->pkg_svc;
328     #return { error => 'Only primary user may log in.' } 
329     #  if $conf->exists('selfservice_server-primary_only')
330     #    && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' );
331     my $part_pkg = $cust_pkg->part_pkg;
332     return { error => 'Only primary user may log in.' }
333       if $conf->exists('selfservice_server-primary_only')
334          && $cust_svc->svcpart != $part_pkg->svcpart([qw( svc_acct svc_phone )]);
335
336   }
337
338   my $session_id;
339   do {
340     $session_id = sha512_hex(time(). {}. rand(). $$)
341   } until ( ! defined _cache->get($session_id) ); #just in case
342
343   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour';
344   _cache->set( $session_id, $session, $timeout );
345
346   return { 'error'      => '',
347            'session_id' => $session_id,
348            %$session,
349          };
350 }
351
352 sub logout {
353   my $p = shift;
354   my $skin_info = skin_info($p);
355   if ( $p->{'session_id'} ) {
356     _cache->remove($p->{'session_id'});
357     return { %$skin_info, 'error' => '' };
358   } else {
359     return { %$skin_info, 'error' => "Can't resume session" }; #better error message
360   }
361 }
362
363 sub switch_acct {
364   my $p = shift;
365
366   my($context, $session, $custnum) = _custoragent_session_custnum($p);
367   return { 'error' => $session } if $context eq 'error';
368
369   my $svc_acct = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct' )
370     or return { 'error' => "Service not found" };
371
372   $session->{'svcnum'} = $svc_acct->svcnum;
373
374   my $conf = new FS::Conf;
375   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour';
376   _cache->set( $p->{'session_id'}, $session, $timeout );
377
378   return { 'error' => '' };
379
380 }
381
382 sub switch_cust {
383   my $p = shift;
384   my($context, $session, $custnum) = _custoragent_session_custnum($p);
385   return { 'error' => $session } if $context eq 'error';
386
387   $session->{'custnum'} = $p->{'custnum'}
388     if exists $session->{'customers'}{ $p->{'custnum'} };
389
390   my $conf = new FS::Conf;
391   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour';
392   _cache->set( $p->{'session_id'}, $session, $timeout );
393
394   return { 'error'      => '',
395            %{ customer_info( { session_id=>$p->{'session_id'} } ) },
396          };
397 }
398
399 sub payment_gateway {
400   # internal use only
401   # takes a cust_main and a cust_payby entry, returns the payment_gateway
402   my $conf = new FS::Conf;
403   my $cust_main = shift;
404   my $cust_payby = shift;
405   my $gatewaynum = $conf->config('selfservice-payment_gateway');
406   if ( $gatewaynum ) {
407     my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum });
408     die "configured gatewaynum $gatewaynum not found!" if !$pg;
409     return $pg;
410   }
411   else {
412     return '' if ! FS::payby->realtime($cust_payby);
413     my $pg = $cust_main->agent->payment_gateway(
414       'method'  => FS::payby->payby2bop($cust_payby),
415       'nofatal' => 1
416     );
417     return $pg;
418   }
419 }
420
421 sub access_info {
422   my $p = shift;
423
424   my $conf = new FS::Conf;
425
426   my $info = skin_info($p);
427
428   use vars qw( $cust_paybys ); #cache for performance
429   unless ( $cust_paybys ) {
430
431     my %cust_paybys = map { $_ => 1 }
432                       map { FS::payby->payby2payment($_) }
433                           $conf->config('signup_server-payby');
434
435     $cust_paybys = [ keys %cust_paybys ];
436
437   }
438   $info->{'cust_paybys'} = $cust_paybys;
439
440   my($context, $session, $custnum) = _custoragent_session_custnum($p);
441   return { 'error' => $session } if $context eq 'error';
442
443   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } );
444
445   $info->{'hide_payment_fields'} = [ 
446     map { 
447       my $pg = $cust_main && payment_gateway($cust_main, $_);
448       $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
449     } @{ $info->{cust_paybys} }
450   ];
451
452   $info->{'self_suspend_reason'} = 
453       $conf->config('selfservice-self_suspend_reason',
454                       $cust_main ? $cust_main->agentnum : ''
455                    );
456
457   $info->{'edit_ticket_subject'} =
458       $conf->exists('ticket_system-selfservice_edit_subject') && 
459       $cust_main && $cust_main->edit_subject;
460
461   $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600;
462
463   $info->{'hide_usage'} = $conf->exists('selfservice_hide-usage');
464
465   return { %$info,
466            'custnum'       => $custnum,
467            'access_pkgnum' => $session->{'pkgnum'},
468            'access_svcnum' => $session->{'svcnum'},
469          };
470 }
471
472 sub customer_info {
473   my $p = shift;
474
475   my($context, $session, $custnum) = _custoragent_session_custnum($p);
476   return { 'error' => $session } if $context eq 'error';
477
478   my %return;
479
480   my $conf = new FS::Conf;
481   $return{'require_address2'} = $conf->exists('cust_main-require_address2');
482
483 #  if ( $FS::TicketSystem::system ) {
484 #    warn "$me customer_info: initializing ticket system\n" if $DEBUG;
485 #    FS::TicketSystem->init();
486 #  }
487  
488   if ( $custnum ) { #customer record
489
490     %return = ( %return, %{ customer_info_short($p) } );
491
492     #redundant with customer_info_short, but we need it for several things below
493     my $search = { 'custnum' => $custnum };
494     $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
495     my $cust_main = qsearchs('cust_main', $search )
496       or return { 'error' => "customer_info: unknown custnum $custnum" };
497
498     my $list_tickets = list_tickets($p);
499     $return{'tickets'} = $list_tickets->{'tickets'};
500
501     if ( $session->{'pkgnum'} ) {
502       #XXX open invoices in the pkg-balances case
503     } else {
504       $return{'money_char'} = $conf->config("money_char") || '$';
505       my @open = map {
506                        {
507                          invnum     => $_->invnum,
508                          date       => time2str("%b %o, %Y", $_->_date),
509                          owed       => $_->owed,
510                          charged    => $_->charged,
511                        };
512                      } $cust_main->open_cust_bill;
513       $return{open_invoices} = \@open;
514
515       my $sql = 'SELECT MAX(_date) FROM cust_bill WHERE custnum = ?';
516       my $sth = dbh->prepare($sql) or die  dbh->errstr;
517       $sth->execute($custnum)      or die $sth->errstr;
518       $return{'last_invoice_date'} = $sth->fetchrow_arrayref->[0];
519       $return{'last_invoice_date_pretty'} =
520         time2str('%m/%d/%Y', $return{'last_invoice_date'} );
521     }
522
523     #customer_info_short always has nobalance on..
524     $return{small_custview} =
525       small_custview( $cust_main,
526                       $return{countrydefault},
527                       ( $session->{'pkgnum'} ? 1 : 0 ), #nobalance
528                     );
529
530     $return{has_ship_address} = $cust_main->has_ship_address;
531     $return{status} = $cust_main->status_label; #$cust_main->status; #better to break anyone obscurely testing for strings in self-service than to have to upgrade every front-end to get the new status to display
532     $return{statuscolor} = $cust_main->statuscolor;
533     $return{status_label} = $cust_main->status_label;
534
535     # compatibility: some places in selfservice use this to determine
536     # if there's a ship address
537     if ( $return{has_ship_address} ) {
538       $return{ship_last}  = $cust_main->last;
539       $return{ship_first} = $cust_main->first;
540     }
541
542     if (scalar($conf->config('support_packages'))) {
543       my @support_services = ();
544       foreach ($cust_main->support_services) {
545         my $seconds = $_->svc_x->seconds || 0;
546         my $time_remaining = (($seconds < 0) ? '-' : '' ).
547                              int(abs($seconds)/3600)."h".
548                              sprintf("%02d",(abs($seconds)%3600)/60)."m";
549         my $cust_pkg = $_->cust_pkg;
550         my $pkgnum = '';
551         my $pkg = '';
552         $pkgnum = $cust_pkg->pkgnum if $cust_pkg;
553         $pkg = $cust_pkg->part_pkg->pkg if $cust_pkg;
554         push @support_services, { svcnum => $_->svcnum,
555                                   time => $time_remaining,
556                                   pkgnum => $pkgnum,
557                                   pkg => $pkg,
558                                 };
559       }
560       $return{support_services} = \@support_services;
561     }
562
563     if ( $conf->config('prepayment_discounts-credit_type') ) {
564       #need to eval?
565       $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
566     }
567
568   } elsif ( $session->{'svcnum'} ) { #no customer record
569
570     my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $session->{'svcnum'} } )
571       or die "unknown svcnum";
572     $return{name} = $svc_acct->email;
573
574   } else {
575
576     return { 'error' => 'Expired session' }; #XXX redirect to login w/this err!
577
578   }
579
580   return { 'error'   => '',
581            'custnum' => $custnum,
582            %return,
583          };
584
585 }
586
587 sub customer_info_short {
588   my $p = shift;
589
590   my($context, $session, $custnum) = _custoragent_session_custnum($p);
591   return { 'error' => $session } if $context eq 'error';
592
593   my %return;
594
595   my $conf = new FS::Conf;
596
597   if ( $custnum ) { #customer record
598
599     my $search = { 'custnum' => $custnum };
600     $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
601     my $cust_main = qsearchs('cust_main', $search )
602       or return { 'error' => "customer_info_short: unknown custnum $custnum" };
603
604     $return{display_custnum} = $cust_main->display_custnum;
605
606     if ( $session->{'pkgnum'} ) { 
607       $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
608       #next_bill_date from cust_pkg?
609     } else {
610       $return{balance} = $cust_main->balance;
611       $return{next_bill_date} = $cust_main->next_bill_date;
612       $return{next_bill_date_pretty} =
613         $return{next_bill_date} ? time2str('%m/%d/%Y', $return{next_bill_date} )
614                                 : '(none)';
615     }
616     $return{balance_pretty} = money_pretty($return{balance});
617
618     $return{countrydefault} = scalar($conf->config('countrydefault'));
619
620     $return{small_custview} =
621       small_custview( $cust_main,
622                       $return{countrydefault},
623                       1, ##nobalance
624                     );
625
626     $return{first}  = $cust_main->first;
627     $return{'last'} = $cust_main->get('last');
628     $return{name}   = $cust_main->first. ' '. $cust_main->get('last');
629
630     $return{payby} = $cust_main->payby;
631
632     #none of these are terribly expensive if we want 'em...
633     for (@cust_main_editable_fields) {
634       $return{$_} = $cust_main->get($_);
635     }
636     #maybe a little more expensive, but it should be cached by now
637     for (@location_editable_fields) {
638       $return{$_} = $cust_main->bill_location->get($_)
639         if $cust_main->bill_locationnum;
640       $return{'ship_'.$_} = $cust_main->ship_location->get($_)
641         if $cust_main->ship_locationnum;
642     }
643  
644     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
645       $return{payinfo} = $cust_main->paymask;
646       @return{'month', 'year'} = $cust_main->paydate_monthyear;
647     }
648     
649     my @invoicing_list = $cust_main->invoicing_list;
650     $return{'invoicing_list'} =
651       join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
652     $return{'postal_invoicing'} =
653       0 < ( grep { $_ eq 'POST' } @invoicing_list );
654
655     if ( $session->{'svcnum'} ) {
656       my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $session->{'svcnum'} });
657       $return{'svc_label'} = ($cust_svc->label)[1] if $cust_svc;
658       $return{'svcnum'} = $session->{'svcnum'};
659     }
660
661   } elsif ( $session->{'svcnum'} ) { #no customer record
662
663     #uuh, not supproted yet... die?
664     return { 'error' => 'customer_info_short not yet supported as agent' };
665
666   } else {
667
668     return { 'error' => 'Expired session' }; #XXX redirect to login w/this err!
669
670   }
671
672   return { 'error'          => '',
673            'custnum'        => $custnum,
674            %return,
675          };
676 }
677
678 sub billing_history {
679   my $p = shift;
680
681   my($context, $session, $custnum) = _custoragent_session_custnum($p);
682   return { 'error' => $session } if $context eq 'error';
683
684   return { 'error' => 'No customer' } unless $custnum;
685
686   my $search = { 'custnum' => $custnum };
687   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
688   my $cust_main = qsearchs('cust_main', $search )
689     or return { 'error' => "unknown custnum $custnum" };
690
691   my %return = ();
692
693   if ( $session->{'pkgnum'} ) { 
694     #$return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
695     #next_bill_date from cust_pkg?
696     return { 'error' => 'No history for package' };
697   }
698
699   $return{balance} = $cust_main->balance;
700   $return{balance_pretty} = money_pretty($return{balance});
701   $return{next_bill_date} = $cust_main->next_bill_date;
702   $return{next_bill_date_pretty} =
703     $return{next_bill_date} ? time2str('%m/%d/%Y', $return{next_bill_date} )
704                             : '(none)';
705
706   my $conf = new FS::Conf;
707
708   $return{'history'} = [
709     $cust_main->payment_history(
710       'line_items' => $conf->exists('selfservice-billing_history-line_items'),
711       'reverse_sort' => 1,
712     )
713   ];
714
715   $return{'money_char'} = $conf->config("money_char") || '$',
716
717   return \%return;
718
719 }
720
721 sub edit_info {
722   my $p = shift;
723   my $session = _cache->get($p->{'session_id'})
724     or return { 'error' => "Can't resume session" }; #better error message
725
726   my $custnum = $session->{'custnum'}
727     or return { 'error' => "no customer record" };
728
729   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
730     or return { 'error' => "unknown custnum $custnum" };
731
732   my $new = new FS::cust_main { $cust_main->hash };
733
734   $new->set( $_ => $p->{$_} )
735     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
736
737   if ( exists($p->{address1}) ) {
738     my $bill_location = FS::cust_location->new({
739         map { $_ => $p->{$_} } @location_editable_fields
740     });
741     # if this is unchanged from before, cust_main::replace will ignore it
742     $new->set('bill_location' => $bill_location);
743   }
744
745   if ( exists($p->{ship_address1}) ) {
746     my $ship_location = FS::cust_location->new({
747         map { $_ => $p->{"ship_$_"} } @location_editable_fields
748     });
749     if ( !grep { length($p->{"ship_$_"}) } @location_editable_fields ) {
750       # Selfservice unfortunately tries to indicate "same as billing 
751       # address" by sending all fields empty.  Did this ever work?
752       $ship_location = $cust_main->bill_location;
753     }
754     $new->set('ship_location' => $ship_location);
755   }
756   # but if it hasn't been passed in at all, leave ship_location alone--
757   # DON'T change it to match bill_location.
758
759   my $payby = '';
760   if (exists($p->{'payby'})) {
761     $p->{'payby'} =~ /^([A-Z]{4})$/
762       or return { 'error' => "illegal_payby " . $p->{'payby'} };
763     $payby = $1;
764   }
765
766   my $conf = new FS::Conf;
767
768   if ( $payby =~ /^(CARD|DCRD)$/ ) {
769
770     $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01');
771
772     if ( $new->payinfo eq $cust_main->paymask ) {
773       $new->payinfo($cust_main->payinfo);
774       $new->paycvv( $p->{'paycvv'} || $cust_main->paycvv );
775     } else {
776       $new->payinfo($p->{'payinfo'});
777       return { 'error' => 'CVV2 is required' }
778         if ! $p->{'paycvv'} && $conf->exists('selfservice-onfile_require_cvv');
779       $new->paycvv( $p->{'paycvv'} )
780     }
781
782     $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
783
784   } elsif ( $payby =~ /^(CHEK|DCHK)$/ ) {
785
786     my $payinfo;
787     $p->{'payinfo1'} =~ /^([\dx]+)$/
788       or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
789     my $payinfo1 = $1;
790      $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ?
791       or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
792     my $payinfo2 = $1;
793     $payinfo = $payinfo1. '@'. $payinfo2;
794
795     $new->payinfo( ($payinfo eq $cust_main->paymask)
796                      ? $cust_main->payinfo
797                      : $payinfo
798                  );
799
800     $new->set( 'payby' => $p->{'auto'} ? 'CHEK' : 'DCHK' );
801
802   } elsif ( $payby =~ /^(BILL)$/ ) {
803     #no-op
804   } elsif ( $payby ) {  #notyet ready
805     return { 'error' => "unknown payby $payby" };
806   }
807
808   my @invoicing_list;
809   if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) {
810     #false laziness with httemplate/edit/process/cust_main.cgi
811     @invoicing_list = split( /\s*\,\s*/, $p->{'invoicing_list'} );
812     push @invoicing_list, 'POST' if $p->{'postal_invoicing'};
813   } else {
814     @invoicing_list = $cust_main->invoicing_list;
815   }
816
817   my $error = $new->replace($cust_main, \@invoicing_list);
818   return { 'error' => $error } if $error;
819   #$cust_main = $new;
820   
821   return { 'error' => '' };
822 }
823
824 sub payment_info {
825   my $p = shift;
826   my $session = _cache->get($p->{'session_id'})
827     or return { 'error' => "Can't resume session" }; #better error message
828
829   ##
830   #generic
831   ##
832
833   my $conf = new FS::Conf;
834   use vars qw($payment_info); #cache for performance
835   unless ( $payment_info ) {
836
837     my %states = map { $_->state => 1 }
838                    qsearch('cust_main_county', {
839                      'country' => $conf->config('countrydefault') || 'US'
840                    } );
841
842     my %cust_paybys = map { $_ => 1 }
843                       map { FS::payby->payby2payment($_) }
844                           $conf->config('signup_server-payby');
845
846     my @cust_paybys = keys %cust_paybys;
847
848     $payment_info = {
849
850       #list all counties/states/countries
851       'cust_main_county' => 
852         [ map { $_->hashref } qsearch('cust_main_county', {}) ],
853
854       #shortcut for one-country folks
855       'states' =>
856         [ sort { $a cmp $b } keys %states ],
857
858       'card_types' => card_types(),
859
860       'withcvv'            => $conf->exists('selfservice-require_cvv'), #or enable optional cvv?
861       'require_cvv'        => $conf->exists('selfservice-require_cvv'),
862       'onfile_require_cvv' => $conf->exists('selfservice-onfile_require_cvv'),
863
864       'paytypes' => [ FS::cust_payby::paytypes ],
865
866       'paybys' => [ $conf->config('signup_server-payby') ],
867       'cust_paybys' => \@cust_paybys,
868
869       'stateid_label' => FS::Msgcat::_gettext('stateid'),
870       'stateid_state_label' => FS::Msgcat::_gettext('stateid_state'),
871
872       'show_ss'  => $conf->exists('show_ss'),
873       'show_stateid' => $conf->exists('show_stateid'),
874       'show_paystate' => $conf->exists('show_bankstate'),
875
876       'save_unchecked' => $conf->exists('selfservice-save_unchecked'),
877
878       'credit_card_surcharge_percentage' => scalar($conf->config('credit-card-surcharge-percentage')),
879     };
880
881   }
882
883   ##
884   #customer-specific
885   ##
886
887   my %return = %$payment_info;
888
889   my $custnum = $session->{'custnum'};
890
891   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
892     or return { 'error' => "unknown custnum $custnum" };
893
894   $return{'hide_payment_fields'} = [
895     map { 
896       my $pg = payment_gateway($cust_main, $_);
897       $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment';
898     } @{ $return{cust_paybys} }
899   ];
900
901   $return{balance} = $cust_main->balance; #XXX pkg-balances?
902
903   $return{payname} = $cust_main->payname
904                      || ( $cust_main->first. ' '. $cust_main->get('last') );
905
906   $return{$_} = $cust_main->bill_location->get($_) 
907     for qw(address1 address2 city state zip);
908
909   $return{payby} = $cust_main->payby;
910   $return{stateid_state} = $cust_main->stateid_state;
911
912   if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
913     $return{card_type} = cardtype($cust_main->payinfo);
914     $return{payinfo} = $cust_main->paymask;
915
916     @return{'month', 'year'} = $cust_main->paydate_monthyear;
917
918   }
919
920   if ( $cust_main->payby =~ /^(CHEK|DCHK)$/ ) {
921     my ($payinfo1, $payinfo2) = split '@', $cust_main->paymask;
922     $return{payinfo1} = $payinfo1;
923     $return{payinfo2} = $payinfo2;
924     $return{paytype}  = $cust_main->paytype;
925     $return{paystate} = $cust_main->paystate;
926     $return{payname}  = $cust_main->payname;    # override 'first/last name' default from above, if any.  Is instution-name here.  (#15819)
927   }
928
929   if ( $conf->config('prepayment_discounts-credit_type') ) {
930     #need to eval?
931     $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
932   }
933
934   #doubleclick protection
935   my $_date = time;
936   $return{payunique} = "webui-MyAccount-$_date-$$-". rand() * 2**32; #new
937   $return{paybatch} = $return{payunique};  #back compat
938
939   return { 'error' => '',
940            %return,
941          };
942
943 }
944
945 #some false laziness with httemplate/process/payment.cgi - look there for
946 #ACH and CVV support stuff
947
948 sub validate_payment {
949   my $p = shift;
950
951   my $session = _cache->get($p->{'session_id'})
952     or return { 'error' => "Can't resume session" }; #better error message
953
954   my $custnum = $session->{'custnum'};
955
956   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
957     or return { 'error' => "unknown custnum $custnum" };
958
959   $p->{'amount'} =~ /^\s*(\d+(\.\d{2})?)\s*$/
960     or return { 'error' => gettext('illegal_amount') };
961   my $amount = $1;
962   return { error => 'Amount must be greater than 0' } unless $amount > 0;
963
964   #false laziness w/tr-amount_fee.html, but we don't want selfservice users
965   #changing the hidden form values
966   my $conf = new FS::Conf;
967   my $fee_display = $conf->config('selfservice_process-display') || 'add';
968   my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
969   my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
970   if ( $fee_display eq 'add'
971          and $fee_pkgpart
972          and ! $fee_skip_first || scalar($cust_main->cust_pay)
973      )
974   {
975     my $fee_pkg = qsearchs('part_pkg', { pkgpart=>$fee_pkgpart } );
976     $amount = sprintf('%.2f', $amount + $fee_pkg->option('setup_fee') );
977   }
978
979   $p->{'discount_term'} =~ /^\s*(\d*)\s*$/
980     or return { 'error' => gettext('illegal_discount_term'). ': '. $p->{'discount_term'} };
981   my $discount_term = $1;
982
983   $p->{'payname'} =~ /^([\w \,\.\-\']+)$/
984     or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} };
985   my $payname = $1;
986
987   $p->{'payunique'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
988     or return { 'error' => gettext('illegal_text'). " payunique: ". $p->{'payunique'} };
989   my $payunique = $1;
990
991   $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
992     or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} };
993   my $paybatch = $1;
994
995   $payunique = $paybatch if ! length($payunique) && length($paybatch);
996
997   $p->{'payby'} ||= 'CARD';
998   $p->{'payby'} =~ /^([A-Z]{4})$/
999     or return { 'error' => "illegal_payby " . $p->{'payby'} };
1000   my $payby = $1;
1001
1002   #false laziness w/process/payment.cgi
1003   my $payinfo;
1004   my $paycvv = '';
1005   if ( $payby eq 'CHEK' || $payby eq 'DCHK' ) {
1006   
1007     $p->{'payinfo1'} =~ /^([\dx]+)$/
1008       or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
1009     my $payinfo1 = $1;
1010      $p->{'payinfo2'} =~ /^([\dx]+)$/
1011       or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
1012     my $payinfo2 = $1;
1013     $payinfo = $payinfo1. '@'. $payinfo2;
1014
1015     $payinfo = $cust_main->payinfo
1016       if $cust_main->paymask eq $payinfo;
1017    
1018   } elsif ( $payby eq 'CARD' || $payby eq 'DCRD' ) {
1019    
1020     $payinfo = $p->{'payinfo'};
1021
1022     my $onfile = 0;
1023
1024     #more intelligent matching will be needed here if you change
1025     #card_masking_method and don't remove existing paymasks
1026     if ( $cust_main->paymask eq $payinfo ) {
1027       $payinfo = $cust_main->payinfo;
1028       $onfile = 1;
1029     }
1030
1031     $payinfo =~ s/\D//g;
1032     $payinfo =~ /^(\d{13,16}|\d{8,9})$/
1033       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
1034     $payinfo = $1;
1035
1036     validate($payinfo)
1037       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
1038     return { 'error' => gettext('unknown_card_type') }
1039       if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown";
1040
1041     if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) {
1042       if ( cardtype($payinfo) eq 'American Express card' ) {
1043         $p->{'paycvv'} =~ /^\s*(\d{4})\s*$/
1044           or return { 'error' => "CVV2 (CID) for American Express cards is four digits." };
1045         $paycvv = $1;
1046       } else {
1047         $p->{'paycvv'} =~ /^\s*(\d{3})\s*$/
1048           or return { 'error' => "CVV2 (CVC2/CID) is three digits." };
1049         $paycvv = $1;
1050       }
1051     } elsif ( $conf->exists('selfservice-onfile_require_cvv') ) {
1052       return { 'error' => 'CVV2 is required' };
1053     } elsif ( !$onfile && $conf->exists('selfservice-require_cvv') ) {
1054       return { 'error' => 'CVV2 is required' };
1055     }
1056   
1057   } else {
1058     die "unknown payby $payby";
1059   }
1060
1061   my %payby2fields = (
1062     'CARD' => [ qw( paystart_month paystart_year payissue payip
1063                     address1 address2 city state zip country    ) ],
1064     'CHEK' => [ qw( ss paytype paystate stateid stateid_state payip ) ],
1065   );
1066
1067   my $card_type = '';
1068   $card_type = cardtype($payinfo) if $payby eq 'CARD';
1069
1070   { 
1071     'cust_main'      => $cust_main, #XXX or just custnum??
1072     'amount'         => sprintf('%.2f', $amount),
1073     'payby'          => $payby,
1074     'payinfo'        => $payinfo,
1075     'paymask'        => $cust_main->mask_payinfo( $payby, $payinfo ),
1076     'card_type'      => $card_type,
1077     'paydate'        => $p->{'year'}. '-'. $p->{'month'}. '-01',
1078     'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'},
1079     'month'          => $p->{'month'},
1080     'year'           => $p->{'year'},
1081     'payname'        => $payname,
1082     'payunique'      => $payunique,
1083     'paybatch'       => $paybatch,
1084     'paycvv'         => $paycvv,
1085     'payname'        => $payname,
1086     'discount_term'  => $discount_term,
1087     'pkgnum'         => $session->{'pkgnum'},
1088     map { $_ => $p->{$_} } ( @{ $payby2fields{$payby} },
1089                              qw( save auto ),
1090                            )
1091   };
1092
1093 }
1094
1095 sub store_payment {
1096   my $p = shift;
1097
1098   my $validate = validate_payment($p);
1099   return $validate if $validate->{'error'};
1100
1101   my $conf = new FS::Conf;
1102   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour'; #?
1103   _cache->set( 'payment_'.$p->{'session_id'}, $validate, $timeout );
1104
1105   +{ map { $_=>$validate->{$_} }
1106       qw( card_type paymask payname paydate_pretty month year amount
1107           address1 address2 city state zip country
1108         )
1109   };
1110
1111 }
1112
1113 sub process_stored_payment {
1114   my $p = shift;
1115
1116   my $session_id = $p->{'session_id'};
1117
1118   my $payment_info = _cache->get( "payment_$session_id" )
1119     or return { 'error' => "Can't resume session" }; #better error message
1120
1121   do_process_payment($payment_info);
1122
1123 }
1124
1125 sub process_payment {
1126   my $p = shift;
1127
1128   my $payment_info = validate_payment($p);
1129   return $payment_info if $payment_info->{'error'};
1130
1131   do_process_payment($payment_info);
1132
1133 }
1134
1135 sub do_process_payment {
1136   my $validate = shift;
1137
1138   my $cust_main = $validate->{'cust_main'};
1139
1140   my $amount = delete $validate->{'amount'};
1141   my $paynum = '';
1142
1143   my $payby = delete $validate->{'payby'};
1144
1145   my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount,
1146     'quiet'       => 1,
1147     'manual'      => 1,
1148     'selfservice' => 1,
1149     'paynum_ref'  => \$paynum,
1150     %$validate,
1151   );
1152   return { 'error' => $error } if $error;
1153
1154   #no error, so order the fee package if applicable...
1155   my $conf = new FS::Conf;
1156   my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
1157   my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
1158   
1159   if ( $fee_pkgpart and ! $fee_skip_first || scalar($cust_main->cust_pay) ) {
1160
1161     my $cust_pkg = new FS::cust_pkg { 'pkgpart' => $fee_pkgpart };
1162
1163     $error = $cust_main->order_pkg( 'cust_pkg' => $cust_pkg );
1164     return { 'error' => "payment processed successfully, but error ordering fee: $error" }
1165       if $error;
1166
1167     #and generate an invoice for it now too
1168     $error = $cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
1169     return { 'error' => "payment processed and fee ordered sucessfully, but error billing fee: $error" }
1170       if $error;
1171
1172   }
1173
1174   $cust_main->apply_payments;
1175
1176   if ( $validate->{'save'} ) {
1177     my $new = new FS::cust_main { $cust_main->hash };
1178     if ($payby eq 'CARD' || $payby eq 'DCRD') {
1179       $new->set( $_ => $validate->{$_} )
1180         foreach qw( payname paystart_month paystart_year payissue payip );
1181       $new->set( 'payby' => $validate->{'auto'} ? 'CARD' : 'DCRD' );
1182
1183       my $bill_location = FS::cust_location->new({
1184           map { $_ => $validate->{$_} } 
1185           qw(address1 address2 city state country zip)
1186       }); # county?
1187       $new->set('bill_location' => $bill_location);
1188       # but don't allow the service address to change this way.
1189
1190     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK') {
1191       $new->set( $_ => $validate->{$_} )
1192         foreach qw( payname payip paytype paystate
1193                     stateid stateid_state );
1194       $new->set( 'payby' => $validate->{'auto'} ? 'CHEK' : 'DCHK' );
1195     }
1196     $new->set( 'payinfo' => $cust_main->card_token || $validate->{'payinfo'} );
1197     $new->set( 'paydate' => $validate->{'paydate'} );
1198     my $error = $new->replace($cust_main);
1199     if ( $error ) {
1200       #no, this causes customers to process their payments again
1201       #return { 'error' => $error };
1202       #XXX just warn verosely for now so i can figure out how these happen in
1203       # the first place, eventually should redirect them to the "change
1204       #address" page but indicate the payment did process??
1205       delete($validate->{'payinfo'}); #don't want to log this!
1206       warn "WARNING: error changing customer info when processing payment (not returning to customer as a processing error): $error\n".
1207            "NEW: ". Dumper($new)."\n".
1208            "OLD: ". Dumper($cust_main)."\n".
1209            "PACKET: ". Dumper($validate)."\n";
1210     #} else {
1211       #not needed...
1212       #$cust_main = $new;
1213     }
1214   }
1215
1216   my $cust_pay = '';
1217   my $receipt_html = '';
1218   if ($paynum) {
1219       # currently supported for realtime CC only; send receipt data to SS
1220       $cust_pay = qsearchs('cust_pay', { 'paynum' => $paynum } );
1221       if($cust_pay) {
1222         $receipt_html = qq!
1223 <TABLE BGCOLOR="#cccccc" BORDER=0 CELLSPACING=2>
1224
1225 <TR>
1226   <TD ALIGN="right">Payment#</TD>
1227   <TD BGCOLOR="#FFFFFF"><B>! . $cust_pay->paynum . qq!</B></TD>
1228 </TR>
1229
1230 <TR>
1231   <TD ALIGN="right">Date</TD>
1232
1233   <TD BGCOLOR="#FFFFFF"><B>! . 
1234         time2str("%a&nbsp;%b&nbsp;%o,&nbsp;%Y&nbsp;%r", $cust_pay->_date)
1235                                                             . qq!</B></TD>
1236 </TR>
1237
1238
1239 <TR>
1240   <TD ALIGN="right">Amount</TD>
1241   <TD BGCOLOR="#FFFFFF"><B>! . sprintf('%.2f', $cust_pay->paid) . qq!</B></TD>
1242
1243 </TR>
1244
1245 <TR>
1246   <TD ALIGN="right">Payment method</TD>
1247   <TD BGCOLOR="#FFFFFF"><B>! . $cust_pay->payby_name .' #'. $cust_pay->paymask
1248                                                                 . qq!</B></TD>
1249 </TR>
1250
1251 </TABLE>
1252 !;
1253       }
1254   }
1255
1256   if ( $cust_pay ) {
1257
1258     return {
1259       'error'        => '',
1260       'amount'       => sprintf('%.2f', $cust_pay->paid),
1261       'date'         => $cust_pay->_date,
1262       'date_pretty'  => time2str('%Y-%m-%d', $cust_pay->_date),
1263       'time_pretty'  => time2str('%T', $cust_pay->_date),
1264       'auth_num'     => $cust_pay->auth,
1265       'order_num'    => $cust_pay->order_number,
1266       'receipt_html' => $receipt_html,
1267     };
1268
1269   } else {
1270
1271     return {
1272       'error'        => '',
1273       'receipt_html' => '',
1274     };
1275
1276   }
1277
1278 }
1279
1280 sub realtime_collect {
1281   my $p = shift;
1282
1283   my $session = _cache->get($p->{'session_id'})
1284     or return { 'error' => "Can't resume session" }; #better error message
1285
1286   my $custnum = $session->{'custnum'};
1287
1288   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1289     or return { 'error' => "unknown custnum $custnum" };
1290
1291   my $amount;
1292   if ( $p->{'amount'} ) {
1293     $amount = $p->{'amount'};
1294   }
1295   elsif ( $session->{'pkgnum'} ) {
1296     $amount = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
1297   }
1298   else {
1299     $amount = $cust_main->balance;
1300   }
1301
1302   my $error = $cust_main->realtime_collect(
1303     'method'     => $p->{'method'},
1304     'amount'     => $amount,
1305     'pkgnum'     => $session->{'pkgnum'},
1306     'session_id' => $p->{'session_id'},
1307     'apply'      => 1,
1308     'selfservice'=> 1,
1309   );
1310   return { 'error' => $error } unless ref( $error );
1311
1312   return { 'error' => '', amount => $amount, %$error };
1313 }
1314
1315 sub start_thirdparty {
1316   my $p = shift;
1317   my $session = _cache->get($p->{'session_id'})
1318     or return { 'error' => "Can't resume session" }; #better error message
1319   my $custnum = $session->{'custnum'};
1320   my $cust_main = FS::cust_main->by_key($custnum);
1321   
1322   my $amount = $p->{'amount'}
1323     or return { error => 'no amount' };
1324
1325   my $result = $cust_main->create_payment(
1326     'method'      => $p->{'method'},
1327     'amount'      => $p->{'amount'},
1328     'pkgnum'      => $session->{'pkgnum'},
1329     'session_id'  => $p->{'session_id'},
1330   );
1331   
1332   if ( ref($result) ) { # hashref or error
1333     return $result;
1334   } else {
1335     return { error => $result };
1336   }
1337 }
1338
1339 sub finish_thirdparty {
1340   my $p = shift;
1341   my $session_id = delete $p->{'session_id'};
1342   my $session = _cache->get($session_id)
1343     or return { 'error' => "Can't resume session" };
1344   my $custnum = $session->{'custnum'};
1345   my $cust_main = FS::cust_main->by_key($custnum);
1346
1347   if ( $p->{_cancel} ) {
1348     # customer backed out of making a payment
1349     return $cust_main->cancel_payment( $session_id );
1350   }
1351   my $result = $cust_main->execute_payment( $session_id, %$p );
1352   if ( ref($result) ) {
1353     return $result;
1354   } else {
1355     return { error => $result };
1356   }
1357 }
1358
1359 sub process_payment_order_pkg {
1360   my $p = shift;
1361
1362   my $hr = process_payment($p);
1363   return $hr if $hr->{'error'};
1364
1365   order_pkg($p);
1366 }
1367
1368 sub process_payment_order_renew {
1369   my $p = shift;
1370
1371   my $hr = process_payment($p);
1372   return $hr if $hr->{'error'};
1373
1374   order_renew($p);
1375 }
1376
1377 sub process_prepay {
1378
1379   my $p = shift;
1380
1381   my $session = _cache->get($p->{'session_id'})
1382     or return { 'error' => "Can't resume session" }; #better error message
1383
1384   my %return;
1385
1386   my $custnum = $session->{'custnum'};
1387
1388   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1389     or return { 'error' => "unknown custnum $custnum" };
1390
1391   my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = ( 0, 0, 0, 0, 0 );
1392   my $error = $cust_main->recharge_prepay( $p->{'prepaid_cardnum'},
1393                                            \$amount,
1394                                            \$seconds,
1395                                            \$upbytes,
1396                                            \$downbytes,
1397                                            \$totalbytes,
1398                                          );
1399
1400   return { 'error' => $error } if $error;
1401
1402   return { 'error'     => '',
1403            'amount'    => $amount,
1404            'seconds'   => $seconds,
1405            'duration'  => duration_exact($seconds),
1406            'upbytes'   => $upbytes,
1407            'upload'    => FS::UI::bytecount::bytecount_unexact($upbytes),
1408            'downbytes' => $downbytes,
1409            'download'  => FS::UI::bytecount::bytecount_unexact($downbytes),
1410            'totalbytes'=> $totalbytes,
1411            'totalload' => FS::UI::bytecount::bytecount_unexact($totalbytes),
1412          };
1413
1414 }
1415
1416 sub invoice {
1417   my $p = shift;
1418   my $session = _cache->get($p->{'session_id'})
1419     or return { 'error' => "Can't resume session" }; #better error message
1420
1421   my $custnum = $session->{'custnum'};
1422
1423   my $invnum = $p->{'invnum'};
1424
1425   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
1426                                           'custnum' => $custnum } )
1427     or return { 'error' => "Can't find invnum" };
1428
1429   #my %return;
1430
1431   return { 'error'        => '',
1432            'invnum'       => $invnum,
1433            'invoice_text' => join('', $cust_bill->print_text ),
1434            'invoice_html' => $cust_bill->print_html( { unsquelch_cdr => 1 } ),
1435          };
1436
1437 }
1438
1439 sub invoice_pdf {
1440   my $p = shift;
1441   my $session = _cache->get($p->{'session_id'})
1442     or return { 'error' => "Can't resume session" }; #better error message
1443
1444   my $custnum = $session->{'custnum'};
1445
1446   my $invnum = $p->{'invnum'};
1447
1448   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
1449                                           'custnum' => $custnum } )
1450     or return { 'error' => "Can't find invnum" };
1451
1452   #my %return;
1453
1454   return { 'error'       => '',
1455            'invnum'      => $invnum,
1456            'invoice_pdf' => $cust_bill->print_pdf({
1457                               'unsquelch_cdr' => 1,
1458                               'locale'        => $p->{'locale'},
1459                             }),
1460          };
1461
1462 }
1463
1464 sub legacy_invoice {
1465   my $p = shift;
1466   my $session = _cache->get($p->{'session_id'})
1467     or return { 'error' => "Can't resume session" }; #better error message
1468
1469   my $custnum = $session->{'custnum'};
1470
1471   my $legacyinvnum = $p->{'legacyinvnum'};
1472
1473   my %hash = (
1474     'legacyinvnum' => $legacyinvnum,
1475     'custnum'      => $custnum,
1476   );
1477
1478   my $legacy_cust_bill =
1479          qsearchs('legacy_cust_bill', { %hash, 'locale' => $p->{'locale'} } )
1480       || qsearchs('legacy_cust_bill', \%hash )
1481     or return { 'error' => "Can't find legacyinvnum" };
1482
1483   #my %return;
1484
1485   return { 'error'        => '',
1486            'legacyinvnum' => $legacyinvnum,
1487            'legacyid'     => $legacy_cust_bill->legacyid,
1488            'invoice_html' => $legacy_cust_bill->content_html,
1489          };
1490
1491 }
1492
1493 sub legacy_invoice_pdf {
1494   my $p = shift;
1495   my $session = _cache->get($p->{'session_id'})
1496     or return { 'error' => "Can't resume session" }; #better error message
1497
1498   my $custnum = $session->{'custnum'};
1499
1500   my $legacyinvnum = $p->{'legacyinvnum'};
1501
1502   my $legacy_cust_bill = qsearchs('legacy_cust_bill', {
1503     'legacyinvnum' => $legacyinvnum,
1504     'custnum'      => $custnum,
1505   }) or return { 'error' => "Can't find legacyinvnum" };
1506
1507   #my %return;
1508
1509   return { 'error'        => '',
1510            'legacyinvnum' => $legacyinvnum,
1511            'legacyid'     => $legacy_cust_bill->legacyid,
1512            'invoice_pdf'  => $legacy_cust_bill->content_pdf,
1513          };
1514
1515 }
1516
1517 sub invoice_logo {
1518   my $p = shift;
1519
1520   #sessioning for this?  how do we get the session id to the backend invoice
1521   # template so it can add it to the link, blah
1522
1523   my $agentnum = '';
1524   if ( $p->{'invnum'} ) {
1525     my $cust_bill = qsearchs('cust_bill', { 'invnum' => $p->{'invnum'} } )
1526       or return { 'error' => 'unknown invnum' };
1527     $agentnum = $cust_bill->cust_main->agentnum;
1528   }
1529
1530   my $templatename = $p->{'template'} || $p->{'templatename'};
1531
1532   #false laziness-ish w/view/cust_bill-logo.cgi
1533
1534   my $conf = new FS::Conf;
1535   if ( $templatename =~ /^([^\.\/]*)$/ && $conf->exists("logo_$1.png") ) {
1536     $templatename = "_$1";
1537   } else {
1538     $templatename = '';
1539   }
1540
1541   my $filename = "logo$templatename.png";
1542
1543   return { 'error'        => '',
1544            'logo'         => $conf->config_binary($filename, $agentnum),
1545            'content_type' => 'image/png', #should allow gif, jpg too
1546          };
1547 }
1548
1549
1550 sub list_invoices {
1551   my $p = shift;
1552   my $session = _cache->get($p->{'session_id'})
1553     or return { 'error' => "Can't resume session" }; #better error message
1554
1555   my $custnum = $session->{'custnum'};
1556
1557   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1558     or return { 'error' => "unknown custnum $custnum" };
1559
1560   my $conf = new FS::Conf;
1561
1562   my @legacy_cust_bill = $cust_main->legacy_cust_bill;
1563
1564   my @cust_bill = grep ! $_->hide, $cust_main->cust_bill;
1565
1566   my $balance = 0;
1567   my $invoices = [
1568     map {
1569       #not super efficient, we also run cust_bill_pay/cust_credited inside owed
1570       my @payments_and_credits = sort {$b->_date <=> $a->_date} ($_->cust_bill_pay,$_->cust_credited);
1571       my $owed = $_->owed;
1572       $balance += $owed;
1573       +{ 'invnum'       => $_->invnum,
1574          '_date'        => $_->_date,
1575          'date'         => time2str("%b %o, %Y", $_->_date),
1576          'date_short'   => time2str("%m-%d-%Y",  $_->_date),
1577          'previous'     => sprintf('%.2f', ($_->previous)[0]),
1578          'charged'      => sprintf('%.2f', $_->charged),
1579          'owed'         => sprintf('%.2f', $owed),
1580          'balance'      => sprintf('%.2f', $balance),
1581          'lastpay'      => @payments_and_credits 
1582                            ? time2str("%b %o, %Y", $payments_and_credits[0]->_date)
1583                            : '',
1584       }
1585     } @cust_bill
1586   ];
1587
1588   return  { 'error'       => '',
1589             'balance'     => $cust_main->balance,
1590             'money_char'  => $conf->config("money_char") || '$',
1591             'invoices'    => $invoices,
1592             'legacy_invoices' => [
1593               map {
1594                     +{ 'legacyinvnum' => $_->legacyinvnum,
1595                        'legacyid'     => $_->legacyid,
1596                        '_date'        => $_->_date,
1597                        'date'         => time2str("%b %o, %Y", $_->_date),
1598                        'date_short'   => time2str("%m-%d-%Y",  $_->_date),
1599                        'charged'      => sprintf('%.2f', $_->charged),
1600                        'has_content'  => (    length($_->content_pdf)
1601                                            || length($_->content_html) ),
1602                      }
1603                   }
1604                   @legacy_cust_bill
1605             ],
1606           };
1607 }
1608
1609 sub cancel {
1610   my $p = shift;
1611   my $session = _cache->get($p->{'session_id'})
1612     or return { 'error' => "Can't resume session" }; #better error message
1613
1614   my $custnum = $session->{'custnum'};
1615
1616   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
1617     or return { 'error' => "unknown custnum $custnum" };
1618
1619   my @errors = $cust_main->cancel( 'quiet'=>1 );
1620
1621   my $error = scalar(@errors) ? join(' / ', @errors) : '';
1622
1623   return { 'error' => $error };
1624
1625 }
1626
1627 sub list_pkgs {
1628   my $p = shift;
1629
1630   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1631   return { 'error' => $session } if $context eq 'error';
1632
1633   my $search = { 'custnum' => $custnum };
1634   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1635   my $cust_main = qsearchs('cust_main', $search )
1636     or return { 'error' => "unknown custnum $custnum" };
1637
1638   my $conf = new FS::Conf;
1639   my $immutable = $conf->exists('selfservice_immutable-package');
1640   
1641 # the duplication below is necessary:
1642 # 1. to maintain the current buggy behaviour wrt the cust_pkg and part_pkg
1643 # hashes overwriting each other (setup and no_auto fields). Fixing that is a
1644 # non-backwards-compatible change breaking the software of anyone using the API
1645 # instead of the stock selfservice
1646 # 2. to return cancelled packages as well - for wholesale and non-wholesale
1647   if( $conf->exists('selfservice_server-view-wholesale') ) {
1648     return { 'svcnum'   => $session->{'svcnum'},
1649             'custnum'  => $custnum,
1650             'cust_pkg' => [ map {
1651                           { $_->hash,
1652                             immutable => $immutable,
1653                             part_pkg => [ map $_->hashref, $_->part_pkg ],
1654                             part_svc =>
1655                               [ map $_->hashref, $_->available_part_svc ],
1656                             cust_svc => 
1657                               [ map { my $ref = { $_->hash,
1658                                                   label => [ $_->label ],
1659                                                 };
1660                                       $ref->{_password} = $_->svc_x->_password
1661                                         if $context eq 'agent'
1662                                         && $conf->exists('agent-showpasswords')
1663                                         && $_->part_svc->svcdb eq 'svc_acct';
1664                                       $ref;
1665                                     } $_->cust_svc
1666                               ],
1667                           };
1668                         } $cust_main->cust_pkg
1669                   ],
1670     'small_custview' =>
1671       small_custview( $cust_main, $conf->config('countrydefault') ),
1672     'wholesale_view' => 1,
1673     'login_svcpart' => [ $conf->config('selfservice_server-login_svcpart') ],
1674     'date_format' => $conf->config('date_format') || '%m/%d/%Y',
1675     'lnp' => $conf->exists('svc_phone-lnp'),
1676       };
1677   }
1678
1679   { 'svcnum'   => $session->{'svcnum'},
1680     'custnum'  => $custnum,
1681     'cust_pkg' => [ map {
1682                           my $primary_cust_svc = $_->primary_cust_svc;
1683                           +{ $_->hash,
1684                             $_->part_pkg->hash,
1685                             immutable   => $immutable,
1686                             pkg_label   => $_->pkg_locale,
1687                             status      => $_->status,
1688                             statuscolor => $_->statuscolor,
1689                             part_svc =>
1690                               [ map { $_->hashref }
1691                                   grep { $_->selfservice_access ne 'hidden' }
1692                                     $_->available_part_svc
1693                               ],
1694                             cust_svc => 
1695                               [ map { my $ref = { $_->hash,
1696                                                   label => [ $_->label ],
1697                                                 };
1698                                       $ref->{_password} = $_->svc_x->_password
1699                                         if $context eq 'agent'
1700                                         && $conf->exists('agent-showpasswords')
1701                                         && $_->part_svc->svcdb eq 'svc_acct';
1702                                       $ref->{svchash} = { $_->svc_x->hash } if 
1703                                         $_->part_svc->svcdb eq 'svc_phone';
1704                                       $ref->{svchash}->{svcpart} =  $_->part_svc->svcpart
1705                                         if $_->part_svc->svcdb eq 'svc_phone'; # hack
1706                                       $ref;
1707                                     }
1708                                   grep { $_->part_svc->selfservice_access ne 'hidden' }
1709                                     $_->cust_svc
1710                               ],
1711                             primary_cust_svc =>
1712                               $primary_cust_svc
1713                                 ? { $primary_cust_svc->hash,
1714                                     label => [ $primary_cust_svc->label ],
1715                                     finger => $primary_cust_svc->svc_x->finger, #uuh
1716                                     $primary_cust_svc->part_svc->hash,
1717                                   }
1718                                 : {}, #'' ?
1719                           };
1720                         } $cust_main->ncancelled_pkgs
1721                   ],
1722     'small_custview' =>
1723       small_custview( $cust_main, $conf->config('countrydefault') ),
1724     'date_format' => $conf->config('date_format') || '%m/%d/%Y',
1725   };
1726
1727 }
1728
1729 sub list_svcs {
1730   my $p = shift;
1731
1732   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1733   return { 'error' => $session } if $context eq 'error';
1734
1735   my $conf = new FS::Conf;
1736
1737   my $hide_usage = $conf->exists('selfservice_hide-usage') ? 1 : 0;
1738   my $search = { 'custnum' => $custnum };
1739   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
1740   my $cust_main = qsearchs('cust_main', $search )
1741     or return { 'error' => "unknown custnum $custnum" };
1742
1743   my $pkgnum = $session->{'pkgnum'} || $p->{'pkgnum'} || '';
1744   if ( ! $pkgnum && $p->{'svcnum'} ) {
1745     my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $p->{'svcnum'} } );
1746     $pkgnum = $cust_svc->pkgnum if $cust_svc;
1747   }
1748
1749   my @cust_svc = ();
1750   my @cust_pkg_usage = ();
1751   foreach my $cust_pkg ( $p->{'ncancelled'} 
1752                          ? $cust_main->ncancelled_pkgs
1753                          : $cust_main->unsuspended_pkgs ) {
1754     next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
1755     push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
1756     push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
1757   }
1758
1759   @cust_svc = grep { $_->part_svc->selfservice_access ne 'hidden' } @cust_svc;
1760   my %usage_pools;
1761   if (!$hide_usage) {
1762     foreach (@cust_pkg_usage) {
1763       my $part = $_->part_pkg_usage;
1764       my $tag = $part->description . ($part->shared ? 1 : 0);
1765       my $row = $usage_pools{$tag} 
1766             ||= [ $part->description, 0, 0, $part->shared ? 1 : 0 ];
1767       $row->[1] += sprintf('%.1f', $_->minutes); # minutes remaining
1768       $row->[2] += $part->minutes; # minutes total
1769     }
1770   } # otherwise just leave them empty
1771
1772   if ( $p->{'svcdb'} ) {
1773     my $svcdb = ref($p->{'svcdb'}) eq 'HASH'
1774                   ? $p->{'svcdb'}
1775                   : ref($p->{'svcdb'}) eq 'ARRAY'
1776                     ? { map { $_=>1 } @{ $p->{'svcdb'} } }
1777                     : { $p->{'svcdb'} => 1 };
1778     @cust_svc = grep $svcdb->{ $_->part_svc->svcdb }, @cust_svc
1779   }
1780
1781   #@svc_x = sort { $a->domain cmp $b->domain || $a->username cmp $b->username }
1782   #              @svc_x;
1783
1784   my @svcs; # stuff to return to the client
1785   foreach my $cust_svc (@cust_svc) {
1786     my $svc_x = $cust_svc->svc_x;
1787     my($label, $value) = $cust_svc->label;
1788     my $part_svc = $cust_svc->part_svc;
1789     my $svcdb = $part_svc->svcdb;
1790     my $cust_pkg = $cust_svc->cust_pkg;
1791     my $part_pkg = $cust_pkg->part_pkg;
1792
1793     my %hash = (
1794       'svcnum'         => $cust_svc->svcnum,
1795       'display_svcnum' => $cust_svc->display_svcnum,
1796       'svcdb'          => $svcdb,
1797       'label'          => $label,
1798       'value'          => $value,
1799       'pkg_label'      => $cust_pkg->pkg_locale,
1800       'pkg_status'     => $cust_pkg->status,
1801       'readonly'       => ($part_svc->selfservice_access eq 'readonly'),
1802     );
1803
1804     # would it make sense to put this in a svc_* method?
1805
1806     if ( $svcdb eq 'svc_acct' ) {
1807       foreach (qw(username email finger seconds)) {
1808         $hash{$_} = $svc_x->$_;
1809       }
1810
1811       if (!$hide_usage) {
1812         %hash = (
1813           %hash,
1814           'upbytes'    => display_bytecount($svc_x->upbytes),
1815           'downbytes'  => display_bytecount($svc_x->downbytes),
1816           'totalbytes' => display_bytecount($svc_x->totalbytes),
1817
1818           'recharge_amount'  => $part_pkg->option('recharge_amount',1),
1819           'recharge_seconds' => $part_pkg->option('recharge_seconds',1),
1820           'recharge_upbytes'    =>
1821             display_bytecount($part_pkg->option('recharge_upbytes',1)),
1822           'recharge_downbytes'  =>
1823             display_bytecount($part_pkg->option('recharge_downbytes',1)),
1824           'recharge_totalbytes' =>
1825             display_bytecount($part_pkg->option('recharge_totalbytes',1)),
1826           # more...
1827         );
1828       }
1829
1830     } elsif ( $svcdb eq 'svc_dsl' ) {
1831
1832       $hash{'phonenum'} = $svc_x->phonenum;
1833       if ( $svc_x->first || $svc_x->get('last') || $svc_x->company ) {
1834         $hash{'name'} = $svc_x->first. ' '. $svc_x->get('last');
1835         $hash{'name'} = $svc_x->company. ' ('. $hash{'name'}. ')'
1836           if $svc_x->company;
1837       } else {
1838         $hash{'name'} = $cust_main->name;
1839       }
1840       # no usage to hide here
1841
1842     } elsif ( $svcdb eq 'svc_phone' or $svcdb eq 'svc_pbx' ) {
1843       if (!$hide_usage) {
1844         # could potentially show lots of things...
1845         $hash{'outbound'} = 1;
1846         $hash{'inbound'}  = 0;
1847         if ( $svcdb eq 'svc_phone' ) {
1848           if ( $part_pkg->plan eq 'voip_inbound' ) {
1849             $hash{'outbound'} = 0;
1850             $hash{'inbound'}  = 1;
1851           } elsif ( $part_pkg->option('selfservice_inbound_format')
1852                 or  $conf->config('selfservice-default_inbound_cdr_format')
1853           ) {
1854             $hash{'inbound'}  = 1;
1855           }
1856         }
1857         foreach (qw(inbound outbound)) {
1858           # hmm...we can't filter by status here, because there might
1859           # not be cdr_terminations at all.  have to go by date.
1860           # find all since the last bill date.
1861           # XXX cdr types?  we are going to need them.
1862           if ( $hash{$_} ) {
1863             my $sum_cdr = $svc_x->sum_cdrs(
1864               'inbound' => ( $_ eq 'inbound' ? 1 : 0 ),
1865               'begin'   => ($cust_pkg->last_bill || 0),
1866               'nonzero' => 1,
1867               'disable_charged_party' => 1,
1868             );
1869             $hash{$_} = $sum_cdr->hashref;
1870           }
1871         }
1872       } # not hiding usage
1873     } # svcdb
1874
1875     push @svcs, \%hash;
1876   } # foreach $cust_svc
1877
1878   return { 
1879     'svcnum'   => $session->{'svcnum'},
1880     'custnum'  => $custnum,
1881     'date_format' => $conf->config('date_format') || '%m/%d/%Y',
1882     'view_usage_nodomain' => $conf->exists('selfservice-view_usage_nodomain'),
1883     'svcs'     => \@svcs,
1884     'usage_pools' => [
1885       map { $usage_pools{$_} }
1886       sort { $a cmp $b }
1887       keys %usage_pools
1888     ],
1889     'hide_usage' => $hide_usage,
1890   };
1891
1892 }
1893
1894 sub _customer_svc_x {
1895   my($custnum, $svcnum, $table) = (shift, shift, shift);
1896   my $hashref = ref($svcnum) ? $svcnum : { 'svcnum' => $svcnum };
1897
1898   $custnum =~ /^(\d+)$/ or die "illegal custnum";
1899   my $search = " AND custnum = $1";
1900   #$search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent';
1901
1902   qsearchs( {
1903     'table'     => ($table || 'svc_acct'),
1904     'addl_from' => 'LEFT JOIN cust_svc  USING ( svcnum  ) '.
1905                    'LEFT JOIN cust_pkg  USING ( pkgnum  ) ',#.
1906                    #'LEFT JOIN cust_main USING ( custnum ) ',
1907     'hashref'   => $hashref,
1908     'extra_sql' => $search, #important
1909   } );
1910
1911 }
1912
1913 sub svc_status_html {
1914   my $p = shift;
1915
1916   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1917   return { 'error' => $session } if $context eq 'error';
1918
1919   #XXX only svc_dsl for now
1920   my $svc_x = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl')
1921     or return { 'error' => "Service not found" };
1922
1923   my $html = $svc_x->getstatus_html;
1924
1925   return { 'html' => $html };
1926
1927 }
1928
1929 sub svc_status_hash {
1930   my $p = shift;
1931
1932   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1933   return { 'error' => $session } if $context eq 'error';
1934
1935   #XXX only svc_acct for now
1936   my $svc_x = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct')
1937     or return { 'error' => "Service not found" };
1938
1939   my ( $html, $hashref ) = $svc_x->export_getstatus;
1940   return $hashref;
1941
1942 }
1943
1944 sub set_svc_status_hash    { _svc_method_X(shift, 'export_setstatus') }
1945 sub set_svc_status_listadd { _svc_method_X(shift, 'export_setstatus_listadd') }
1946 sub set_svc_status_listdel { _svc_method_X(shift, 'export_setstatus_listdel') }
1947 sub set_svc_status_vacationadd { _svc_method_X(shift, 'export_setstatus_vacationadd') }
1948 sub set_svc_status_vacationdel { _svc_method_X(shift, 'export_setstatus_vacationdel') }
1949
1950 sub _svc_method_X {
1951   my( $p, $method ) = @_;
1952
1953   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1954   return { 'error' => $session } if $context eq 'error';
1955
1956   #XXX only svc_acct for now
1957   my $svc_x = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct')
1958     or return { 'error' => "Service not found" };
1959
1960   warn "$method ". join(' / ', map "$_=>".$p->{$_}, keys %$p )
1961     if $DEBUG;
1962   my $error = $svc_x->$method($p); #$p? returns error?
1963   return { 'error' => $error } if $error;
1964
1965   return {}; #? { 'error' => '' }
1966
1967 }
1968
1969 sub acct_forward_info {
1970   my $p = shift;
1971
1972   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1973   return { 'error' => $session } if $context eq 'error';
1974
1975   my $svc_forward = _customer_svc_x( $custnum,
1976                                      { 'srcsvc' => $p->{'svcnum'} },
1977                                      'svc_forward',
1978                                    )
1979     or return { 'error' => '',
1980                 'dst'   => '',
1981               };
1982
1983   return { 'error' => '',
1984            'dst'   => $svc_forward->dst || $svc_forward->dstsvc_acct->email,
1985          };
1986
1987 }
1988
1989 sub process_acct_forward {
1990   my $p = shift;
1991   my($context, $session, $custnum) = _custoragent_session_custnum($p);
1992   return { 'error' => $session } if $context eq 'error';
1993
1994   my $old = _customer_svc_x( $custnum,
1995                              { 'srcsvc' => $p->{'svcnum'} },
1996                              'svc_forward',
1997                            );
1998
1999   if ( $p->{'dst'} eq '' ) {
2000     if ( $old ) {
2001       my $error = $old->delete;
2002       return { 'error' => $error };
2003     }
2004     return { 'error' => '' };
2005   }
2006
2007   my $new = new FS::svc_forward { 'srcsvc' => $p->{'svcnum'},
2008                                   'dst'    => $p->{'dst'},
2009                                 };
2010
2011   my $error;
2012   if ( $old ) {
2013     $new->svcnum($old->svcnum);
2014     my $cust_svc = $old->cust_svc;
2015     $new->svcpart($old->svcpart);
2016     $new->pkgnuym($old->pkgnum);
2017     $error = $new->replace($old);
2018   } else {
2019     my $conf = new FS::Conf;
2020     $new->svcpart($conf->config('selfservice-svc_forward_svcpart'));
2021
2022     my $svc_acct = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_acct' )
2023       or return { 'error' => 'No service' }; #how would we even get here?
2024
2025     $new->pkgnum( $svc_acct->cust_svc->pkgnum );
2026
2027     $error = $new->insert;
2028   }
2029
2030   return { 'error' => $error };
2031
2032 }
2033
2034 sub list_dsl_devices {
2035   my $p = shift;
2036
2037   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2038   return { 'error' => $session } if $context eq 'error';
2039
2040   my $svc_dsl = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl' )
2041     or return { 'error' => "Service not found" };
2042
2043   return {
2044     'devices' => [ map {
2045                          +{ 'mac_addr' => $_->mac_addr };
2046                        } $svc_dsl->dsl_device
2047                  ],
2048   };
2049
2050 }
2051
2052 sub add_dsl_device {
2053   my $p = shift;
2054
2055   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2056   return { 'error' => $session } if $context eq 'error';
2057
2058   my $svc_dsl = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl' )
2059     or return { 'error' => "Service not found" };
2060
2061   return { 'error' => 'No MAC address supplied' }
2062     unless length($p->{'mac_addr'});
2063
2064   my $dsl_device = new FS::dsl_device { 'svcnum'   => $svc_dsl->svcnum,
2065                                         'mac_addr' => scalar($p->{'mac_addr'}),
2066                                       };
2067   my $error = $dsl_device->insert;
2068   return { 'error' => $error };
2069
2070 }
2071
2072 sub delete_dsl_device {
2073   my $p = shift;
2074
2075   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2076   return { 'error' => $session } if $context eq 'error';
2077
2078   my $svc_dsl = _customer_svc_x( $custnum, $p->{'svcnum'}, 'svc_dsl' )
2079     or return { 'error' => "Service not found" };
2080
2081   my $dsl_device = qsearchs('dsl_device', { 'svcnum'   => $svc_dsl->svcnum,
2082                                             'mac_addr' => scalar($p->{'mac_addr'}),
2083                                           }
2084                            )
2085     or return { 'error' => 'Unknown MAC address: '. $p->{'mac_addr'} };
2086
2087   my $error = $dsl_device->delete;
2088   return { 'error' => $error };
2089
2090 }
2091
2092 sub port_graph {
2093   my $p = shift;
2094   _usage_details( \&_port_graph, $p,
2095                   'svcdb' => 'svc_port',
2096                 );
2097 }
2098
2099 sub _port_graph {
2100   my($svc_port, $begin, $end) = @_;
2101   my @usage = ();
2102   my $pngOrError = $svc_port->graph_png( start=>$begin, end=> $end );
2103   push @usage, { 'png' => $pngOrError };
2104   (@usage);
2105 }
2106
2107 sub _list_svc_usage {
2108   my($svc_acct, $begin, $end) = @_;
2109   my @usage = ();
2110   foreach my $part_export ( 
2111     map { qsearch ( 'part_export', { 'exporttype' => $_ } ) }
2112     qw( sqlradius sqlradius_withdomain )
2113   ) {
2114     push @usage, @ { $part_export->usage_sessions($begin, $end, $svc_acct) };
2115   }
2116   (@usage);
2117 }
2118
2119 sub list_svc_usage {
2120   _usage_details(\&_list_svc_usage, @_);
2121 }
2122
2123 sub _list_support_usage {
2124   my($svc_acct, $begin, $end) = @_;
2125   my @usage = ();
2126   foreach ( grep { $begin <= $_->_date && $_->_date <= $end }
2127             qsearch('acct_rt_transaction', { 'svcnum' => $svc_acct->svcnum })
2128           ) {
2129     push @usage, { 'seconds'  => $_->seconds,
2130                    'support'  => $_->support,
2131                    '_date'    => $_->_date,
2132                    'id'       => $_->transaction_id,
2133                    'creator'  => $_->creator,
2134                    'subject'  => $_->subject,
2135                    'status'   => $_->status,
2136                    'ticketid' => $_->ticketid,
2137                  };
2138   }
2139   (@usage);
2140 }
2141
2142 sub list_support_usage {
2143   _usage_details(\&_list_support_usage, @_);
2144 }
2145
2146 sub _list_cdr_usage {
2147   # XXX CDR type support...
2148   # XXX any way to do a paged search on this?
2149   # we have to return the results all at once...
2150   my($svc_x, $begin, $end, %opt) = @_;
2151   map [ $_->downstream_csv(%opt, 'keeparray' => 1) ],
2152     $svc_x->get_cdrs(
2153       'begin' => $begin,
2154       'end'   => $end,
2155       'disable_charged_party' => 1,
2156       %opt
2157     );
2158 }
2159
2160 sub list_cdr_usage {
2161   my $p = shift;
2162   _usage_details( \&_list_cdr_usage, $p );
2163 }
2164
2165 sub _usage_details {
2166   my($callback, $p, %opt) = @_;
2167   my $conf = FS::Conf->new;
2168
2169   if ( $conf->exists('selfservice_hide-usage') ) {
2170     return { 'error' => 'Viewing usage is not allowed.' };
2171   }
2172
2173   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2174   return { 'error' => $session } if $context eq 'error';
2175
2176   my $search = { 'svcnum' => $p->{'svcnum'} };
2177   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2178
2179   my $cust_svc = qsearchs( 'cust_svc', $search );
2180   return { 'error' => 'No service selected in list_svc_usage' } 
2181     unless $cust_svc;
2182
2183   my $svc_x = $cust_svc->svc_x;
2184   my $svcdb = $svc_x->table;
2185   my $cust_pkg = $cust_svc->cust_pkg;
2186   my $freq     = $cust_pkg->part_pkg->freq;
2187   my %callback_opt;
2188   my $header = [];
2189   if ( $svcdb eq 'svc_phone' or $svcdb eq 'svc_pbx' ) {
2190     my $format = '';
2191     if ( $p->{inbound} ) {
2192       $format = $cust_pkg->part_pkg->option('selfservice_inbound_format') 
2193                 || $conf->config('selfservice-default_inbound_cdr_format')
2194                 || 'source_default';
2195       $callback_opt{inbound} = 1;
2196     } else {
2197       $format = $cust_pkg->part_pkg->option('selfservice_format')
2198                 || $conf->config('selfservice-default_cdr_format')
2199                 || 'default';
2200     }
2201
2202     $callback_opt{format} = $format;
2203     $callback_opt{use_clid} = 1;
2204     $header = [ split(',', FS::cdr::invoice_header($format) ) ];
2205   }
2206
2207   my $start    = $cust_pkg->setup;
2208   #my $end      = $cust_pkg->bill; # or time?
2209   my $end      = time;
2210
2211   unless ( $p->{beginning} ) {
2212     $p->{beginning} = $cust_pkg->last_bill;
2213     $p->{ending}    = $end;
2214   }
2215
2216   die "illegal beginning" if $p->{beginning} !~ /^\d*$/;
2217   die "illegal ending"    if $p->{ending}    !~ /^\d*$/;
2218
2219   my (@usage) = &$callback($svc_x, $p->{beginning}, $p->{ending}, 
2220     %callback_opt
2221   );
2222
2223   if ( $conf->exists('selfservice-hide_cdr_price') ) {
2224     # ugly kludge, I know
2225     my ($delete_col) = grep { $header->[$_] eq 'Price' } (0..scalar(@$header));
2226     if (defined $delete_col) {
2227       delete($_->[$delete_col]) foreach ($header, @usage);
2228     }
2229   }
2230
2231   #kinda false laziness with FS::cust_main::bill, but perhaps
2232   #we should really change this bit to DateTime and DateTime::Duration
2233   #
2234   #change this bit to use Date::Manip? CAREFUL with timezones (see
2235   # mailing list archive)
2236   my ($nsec,$nmin,$nhour,$nmday,$nmon,$nyear) =
2237     (localtime($p->{ending}) )[0,1,2,3,4,5];
2238   my ($psec,$pmin,$phour,$pmday,$pmon,$pyear) =
2239     (localtime($p->{beginning}) )[0,1,2,3,4,5];
2240
2241   if ( $freq =~ /^\d+$/ ) {
2242     $nmon += $freq;
2243     until ( $nmon < 12 ) { $nmon -= 12; $nyear++; }
2244     $pmon -= $freq;
2245     until ( $pmon >= 0 ) { $pmon += 12; $pyear--; }
2246   } elsif ( $freq =~ /^(\d+)w$/ ) {
2247     my $weeks = $1;
2248     $nmday += $weeks * 7;
2249     $pmday -= $weeks * 7;
2250   } elsif ( $freq =~ /^(\d+)d$/ ) {
2251     my $days = $1;
2252     $nmday += $days;
2253     $pmday -= $days;
2254   } elsif ( $freq =~ /^(\d+)h$/ ) {
2255     my $hours = $1;
2256     $nhour += $hours;
2257     $phour -= $hours;
2258   } else {
2259     return { 'error' => "unparsable frequency: ". $freq };
2260   }
2261   
2262   my $previous  = timelocal_nocheck($psec,$pmin,$phour,$pmday,$pmon,$pyear);
2263   my $next      = timelocal_nocheck($nsec,$nmin,$nhour,$nmday,$nmon,$nyear);
2264
2265   { 
2266     'error'     => '',
2267     'svcnum'    => $p->{svcnum},
2268     'beginning' => $p->{beginning},
2269     'ending'    => $p->{ending},
2270     'inbound'   => $p->{inbound},
2271     'previous'  => ($previous > $start) ? $previous : $start,
2272     'next'      => ($next < $end) ? $next : $end,
2273     'header'    => $header,
2274     'usage'     => \@usage,
2275   };
2276 }
2277
2278 sub order_pkg {
2279   my $p = shift;
2280
2281   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2282   return { 'error' => $session } if $context eq 'error';
2283
2284   my $search = { 'custnum' => $custnum };
2285   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2286   my $cust_main = qsearchs('cust_main', $search )
2287     or return { 'error' => "unknown custnum $custnum" };
2288
2289   my $status = $cust_main->status;
2290
2291   my %order_pkg_options = ();
2292   if ( $p->{locationnum} > 0 ) {
2293     $order_pkg_options{locationnum} = delete($p->{locationnum});
2294   } elsif ( $p->{address1} ) {
2295     $order_pkg_options{'cust_location'} = new FS::cust_location {
2296       map { $_ => $p->{$_} }
2297         qw( address1 address2 city county state zip country )
2298     };
2299   }
2300
2301   #false laziness w/ClientAPI/Signup.pm
2302
2303   my $cust_pkg = new FS::cust_pkg ( {
2304     'custnum'  => $custnum,
2305     'pkgpart'  => $p->{'pkgpart'},
2306     'quantity' => $p->{'quantity'} || 1,
2307   } );
2308   my $error = $cust_pkg->check;
2309   return { 'error' => $error } if $error;
2310
2311   my @svc = ();
2312   unless ( $p->{'svcpart'} eq 'none' ) {
2313
2314     my $svcdb;
2315     my $svcpart = '';
2316     if ( $p->{'svcpart'} =~ /^(\d+)$/ ) {
2317       $svcpart = $1;
2318       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2319       return { 'error' => "Unknown svcpart $svcpart" } unless $part_svc;
2320       $svcdb = $part_svc->svcdb;
2321     } else {
2322       $svcdb = 'svc_acct';
2323     }
2324     $svcpart ||= $cust_pkg->part_pkg->svcpart($svcdb);
2325
2326     my %fields = (
2327       'svc_acct'     => [ qw( username domsvc _password sec_phrase popnum ) ],
2328       'svc_domain'   => [ qw( domain ) ],
2329       'svc_phone'    => [ qw( phonenum pin sip_password phone_name ) ],
2330       'svc_external' => [ qw( id title ) ],
2331       'svc_pbx'      => [ qw( id title ) ],
2332     );
2333   
2334     my $svc_x = "FS::$svcdb"->new( {
2335       'svcpart'   => $svcpart,
2336       map { $_ => $p->{$_} } @{$fields{$svcdb}}
2337     } );
2338     
2339     if ( $svcdb eq 'svc_acct' && exists($p->{"snarf_machine1"}) ) {
2340       my @acct_snarf;
2341       my $snarfnum = 1;
2342       while ( length($p->{"snarf_machine$snarfnum"}) ) {
2343         my $acct_snarf = new FS::acct_snarf ( {
2344           'machine'   => $p->{"snarf_machine$snarfnum"},
2345           'protocol'  => $p->{"snarf_protocol$snarfnum"},
2346           'username'  => $p->{"snarf_username$snarfnum"},
2347           '_password' => $p->{"snarf_password$snarfnum"},
2348         } );
2349         $snarfnum++;
2350         push @acct_snarf, $acct_snarf;
2351       }
2352       $svc_x->child_objects( \@acct_snarf );
2353     }
2354     
2355     my $y = $svc_x->setdefault; # arguably should be in new method
2356     return { 'error' => $y } if $y && !ref($y);
2357   
2358     $error = $svc_x->check;
2359     return { 'error' => $error } if $error;
2360
2361     push @svc, $svc_x;
2362
2363   }
2364
2365   $error = $cust_main->order_pkg(
2366     'cust_pkg' => $cust_pkg,
2367     'svcs'     => \@svc,
2368     'noexport' => 1,
2369     %order_pkg_options,
2370   );
2371   return { 'error' => $error } if $error;
2372
2373   my $conf = new FS::Conf;
2374   if ( $conf->exists('signup_server-realtime') ) {
2375
2376     my $bill_error = _do_bop_realtime( $cust_main, $status );
2377
2378     if ($bill_error) {
2379       $cust_pkg->cancel('quiet'=>1);
2380       return $bill_error;
2381     } else {
2382       $cust_pkg->reexport;
2383     }
2384
2385   } else {
2386     $cust_pkg->reexport;
2387   }
2388
2389   my $svcnum = $svc[0] ? $svc[0]->svcnum : '';
2390
2391   return { error=>'', pkgnum=>$cust_pkg->pkgnum, svcnum=>$svcnum };
2392
2393 }
2394
2395 sub change_pkg {
2396   my $p = shift;
2397
2398   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2399   return { 'error' => $session } if $context eq 'error';
2400
2401   my $conf = new FS::Conf;
2402   my $immutable = $conf->exists('selfservice_immutable-package');
2403   return { 'error' => "Package modification disabled" } if $immutable;
2404
2405   my $search = { 'custnum' => $custnum };
2406   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2407   my $cust_main = qsearchs('cust_main', $search )
2408     or return { 'error' => "unknown custnum $custnum" };
2409
2410   my $status = $cust_main->status;
2411   my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $p->{pkgnum} } )
2412     or return { 'error' => "unknown package $p->{pkgnum}" };
2413
2414   #if someone does need self-service package change of suspended packages,
2415   # figure out how to be more discriminating
2416   return { error=>"Can't change a suspended package", pkgnum=>$cust_pkg->pkgnum}
2417     if $cust_pkg->status eq 'suspended';
2418
2419   my $err_or_cust_pkg = $cust_pkg->change( 'pkgpart'  => $p->{'pkgpart'},
2420                                            'quantity' => $p->{'quantity'} || 1,
2421                                          );
2422
2423   return { error=>$err_or_cust_pkg, pkgnum=>$cust_pkg->pkgnum }
2424     unless ref($err_or_cust_pkg);
2425
2426   if ( $conf->exists('signup_server-realtime') ) {
2427
2428     my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_credit'=>1 );
2429
2430     if ($bill_error) {
2431       $err_or_cust_pkg->suspend;
2432       return $bill_error;
2433     } else {
2434       $err_or_cust_pkg->reexport;
2435     }
2436
2437   } else {  
2438     $err_or_cust_pkg->reexport;
2439   }
2440
2441   return { error => '', pkgnum => $cust_pkg->pkgnum };
2442
2443 }
2444
2445 sub order_recharge {
2446   my $p = shift;
2447
2448   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2449   return { 'error' => $session } if $context eq 'error';
2450
2451   my $search = { 'custnum' => $custnum };
2452   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2453   my $cust_main = qsearchs('cust_main', $search )
2454     or return { 'error' => "unknown custnum $custnum" };
2455
2456   my $status = $cust_main->status;
2457   my $cust_svc = qsearchs( 'cust_svc', { 'svcnum' => $p->{'svcnum'} } )
2458     or return { 'error' => "unknown service " . $p->{'svcnum'} };
2459
2460   my $svc_x = $cust_svc->svc_x;
2461   my $part_pkg = $cust_svc->cust_pkg->part_pkg;
2462
2463   my %vhash =
2464     map { $_ =~ /^recharge_(.*)$/; $1, $part_pkg->option($_, 1) } 
2465     qw ( recharge_seconds recharge_upbytes recharge_downbytes
2466          recharge_totalbytes );
2467   my $amount = $part_pkg->option('recharge_amount', 1); 
2468   
2469   my ($l, $v, $d) = $cust_svc->label;  # blah
2470   my $pkg = "Recharge $v"; 
2471
2472   my $bill_error = $cust_main->charge($amount, $pkg,
2473      "time: $vhash{seconds}, up: $vhash{upbytes}," . 
2474      "down: $vhash{downbytes}, total: $vhash{totalbytes}",
2475      $part_pkg->taxclass); #meh
2476
2477   my $conf = new FS::Conf;
2478   if ( $conf->exists('signup_server-realtime') && !$bill_error ) {
2479
2480     $bill_error = _do_bop_realtime( $cust_main, $status );
2481
2482     if ($bill_error) {
2483       return $bill_error;
2484     } else {
2485       my $error = $svc_x->recharge (\%vhash);
2486       return { 'error' => $error } if $error;
2487     }
2488
2489   } else {  
2490     my $error = $bill_error;
2491     $error ||= $svc_x->recharge (\%vhash);
2492     return { 'error' => $error } if $error;
2493   }
2494
2495   return { error => '', svc => $cust_svc->part_svc->svc };
2496
2497 }
2498
2499 sub _do_bop_realtime {
2500   my ($cust_main, $status, %opt) = @_;
2501
2502     my $old_balance = $cust_main->balance;
2503
2504     my $bill_error =    $cust_main->bill
2505                      || $cust_main->apply_payments_and_credits;
2506
2507     $bill_error ||= $cust_main->realtime_collect('selfservice' => 1)
2508       if $cust_main->payby =~ /^(CARD|CHEK)$/;
2509
2510     if (    $cust_main->balance > $old_balance
2511          && $cust_main->balance > 0
2512          && ( $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/
2513                 || $status eq 'suspended'
2514             )
2515        )
2516     {
2517       unless ( $opt{'no_credit'} ) {
2518         #this makes sense.  credit is "un-doing" the invoice
2519         my $conf = new FS::Conf;
2520         $cust_main->credit( sprintf("%.2f", $cust_main->balance-$old_balance ),
2521                             'self-service decline',
2522                             reason_type=>$conf->config('signup_credit_type'),
2523                           );
2524         $cust_main->apply_credits( 'order' => 'newest' );
2525       }
2526
2527       return { 'error' => '_decline', 'bill_error' => $bill_error };
2528     }
2529
2530     '';
2531 }
2532
2533 sub renew_info {
2534   my $p = shift;
2535
2536   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2537   return { 'error' => $session } if $context eq 'error';
2538
2539   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
2540     or return { 'error' => "unknown custnum $custnum" };
2541
2542   my @cust_pkg = sort { $a->bill <=> $b->bill }
2543                  grep { $_->part_pkg->freq ne '0' }
2544                  $cust_main->ncancelled_pkgs;
2545
2546   #return { 'error' => 'No active packages to renew.' } unless @cust_pkg;
2547
2548   my $total = $cust_main->balance;
2549
2550   my @array = map {
2551                     my $bill = $_->bill;
2552                     $total += $_->part_pkg->base_recur($_, \$bill);
2553                     my $renew_date = $_->part_pkg->add_freq($_->bill);
2554                     {
2555                       'pkgnum'             => $_->pkgnum,
2556                       'amount'             => sprintf('%.2f', $total),
2557                       'bill_date'          => $_->bill,
2558                       'bill_date_pretty'   => time2str('%x', $_->bill),
2559                       'renew_date'         => $renew_date,
2560                       'renew_date_pretty'  => time2str('%x', $renew_date),
2561                       'expire_date'        => $_->expire,
2562                       'expire_date_pretty' => time2str('%x', $_->expire),
2563                     };
2564                   }
2565                   @cust_pkg;
2566
2567   return { 'dates' => \@array };
2568
2569 }
2570
2571 sub payment_info_renew_info {
2572   my $p = shift;
2573   my $renew_info   = renew_info($p);
2574   my $payment_info = payment_info($p);
2575   return { %$renew_info,
2576            %$payment_info,
2577          };
2578 }
2579
2580 sub order_renew {
2581   my $p = shift;
2582
2583   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2584   return { 'error' => $session } if $context eq 'error';
2585
2586   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
2587     or return { 'error' => "unknown custnum $custnum" };
2588
2589   my $date = $p->{'date'};
2590
2591   my $now = time;
2592
2593   #freeside-daily -n -d $date fs_daily $custnum
2594   $cust_main->bill_and_collect( 'time'         => $date,
2595                                 'invoice_time' => $now,
2596                                 'actual_time'  => $now,
2597                                 'check_freq'   => '1d',
2598                               );
2599
2600   return { 'error' => '' };
2601
2602 }
2603
2604 sub suspend_pkg {
2605   my $p = shift;
2606   my $session = _cache->get($p->{'session_id'})
2607     or return { 'error' => "Can't resume session" }; #better error message
2608
2609   my $custnum = $session->{'custnum'};
2610
2611   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
2612     or return { 'error' => "unknown custnum $custnum" };
2613
2614   my $conf = new FS::Conf;
2615   my $reasonnum = 
2616     $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum)
2617       or return { 'error' => 'Permission denied' };
2618
2619   my $pkgnum = $p->{'pkgnum'};
2620
2621   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
2622                                         'pkgnum'  => $pkgnum,   } )
2623     or return { 'error' => "unknown pkgnum $pkgnum" };
2624
2625   my $error = $cust_pkg->suspend(reason => $reasonnum);
2626   return { 'error' => $error };
2627
2628 }
2629
2630 sub cancel_pkg {
2631   my $p = shift;
2632   my $session = _cache->get($p->{'session_id'})
2633     or return { 'error' => "Can't resume session" }; #better error message
2634
2635   my $custnum = $session->{'custnum'};
2636
2637   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
2638     or return { 'error' => "unknown custnum $custnum" };
2639
2640   my $pkgnum = $p->{'pkgnum'};
2641
2642   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
2643                                         'pkgnum'  => $pkgnum,   } )
2644     or return { 'error' => "unknown pkgnum $pkgnum" };
2645
2646   my $error = $cust_pkg->cancel('quiet' => 1);
2647   return { 'error' => $error };
2648
2649 }
2650
2651 sub provision_phone {
2652   my $p = shift;
2653   my @bulkdid;
2654   @bulkdid = @{$p->{'bulkdid'}} if $p->{'bulkdid'};
2655
2656   #editing an existing phone number
2657   if ( $p->{'svcnum'} && $p->{'svcnum'} =~ /^\d+$/ ) {
2658       my($context, $session, $custnum) = _custoragent_session_custnum($p);
2659       return { 'error' => $session } if $context eq 'error';
2660     
2661       my $svc_phone = qsearchs('svc_phone', { svcnum => $p->{'svcnum'} });
2662       return { 'error' => 'service not found' } unless $svc_phone;
2663       return { 'error' => 'invalid svcnum' } 
2664         if $svc_phone && $svc_phone->cust_svc->cust_pkg->custnum != $custnum;
2665
2666       $svc_phone->email($p->{'email'}) 
2667         if $svc_phone->email ne $p->{'email'} && $p->{'email'} =~ /^([\w\.\d@]+|)$/;
2668       $svc_phone->forwarddst($p->{'forwarddst'}) 
2669         if $svc_phone->forwarddst ne $p->{'forwarddst'} 
2670             && $p->{'forwarddst'} =~ /^(\d+|)$/;
2671       return { 'error' => $svc_phone->replace };
2672  }
2673
2674   # single DID LNP
2675   unless ( $p->{'lnp'} ) {
2676     $p->{'lnp_desired_due_date'} = parse_datetime($p->{'lnp_desired_due_date'});
2677     $p->{'lnp_status'} = "portingin";
2678     return _provision( 'FS::svc_phone',
2679                   [qw(lnp_desired_due_date lnp_other_provider 
2680                     lnp_other_provider_account phonenum countrycode lnp_status)],
2681                   [qw(phonenum countrycode)],
2682                   $p,
2683                   @_
2684                 );
2685   }
2686
2687   # single DID order (the usual case)
2688   unless (scalar(@bulkdid)) {
2689     return _provision( 'FS::svc_phone',
2690                   [qw(phonenum countrycode)],
2691                   [qw(phonenum countrycode)],
2692                   $p,
2693                   @_
2694                 );
2695   }
2696
2697   # bulk DID order case
2698   my $error;
2699   foreach my $did ( @bulkdid ) {
2700     $did =~ s/[^0-9]//g;
2701     $error = _provision( 'FS::svc_phone',
2702               [qw(phonenum countrycode)],
2703               [qw(phonenum countrycode)],
2704               {
2705                 'pkgnum' => $p->{'pkgnum'},
2706                 'svcpart' => $p->{'svcpart'},
2707                 'phonenum' => $did,
2708                 'countrycode' => $p->{'countrycode'},
2709                 'session_id' => $p->{'session_id'},
2710               }
2711             );
2712     return $error if ($error->{'error'} && length($error->{'error'}) > 1);
2713   }
2714   { 'bulkdid' => [ @bulkdid ], 'svc' => $error->{'svc'} }
2715 }
2716
2717 sub provision_pbx {
2718   my $p = shift;
2719   warn "provision_pbx called\n"
2720     if $DEBUG;
2721
2722   warn "provision_pbx calling _provision\n"
2723     if $DEBUG;
2724   _provision( 'FS::svc_pbx',
2725               [qw(id title max_extensions max_simultaneous ip_addr)],
2726               [qw(id title max_extensions max_simultaneous ip_addr)],
2727               $p,
2728               @_
2729             );
2730 }
2731
2732 sub provision_acct {
2733   my $p = shift;
2734   warn "provision_acct called\n"
2735     if $DEBUG;
2736
2737   return { 'error' => gettext('passwords_dont_match') }
2738     if $p->{'_password'} ne $p->{'_password2'};
2739   return { 'error' => gettext('empty_password') }
2740     unless length($p->{'_password'});
2741  
2742   if ($p->{'domsvc'}) {
2743     my %domains = domain_select_hash FS::svc_acct(map { $_ => $p->{$_} }
2744                                                   qw ( svcpart pkgnum ) );
2745     return { 'error' => gettext('invalid_domain') }
2746       unless ($domains{$p->{'domsvc'}});
2747   }
2748
2749   warn "provision_acct calling _provision\n"
2750     if $DEBUG;
2751   _provision( 'FS::svc_acct',
2752               [qw(username _password domsvc)],
2753               [qw(username _password domsvc)],
2754               $p,
2755               @_
2756             );
2757 }
2758
2759 sub provision_external {
2760   my $p = shift;
2761   #_provision( 'FS::svc_external', [qw(id title)], [qw(id title)], $p, @_ );
2762   _provision( 'FS::svc_external',
2763               [],
2764               [qw(id title)],
2765               $p,
2766               @_
2767             );
2768 }
2769
2770 sub provision_forward {
2771   my $p = shift;
2772   _provision( 'FS::svc_forward',
2773               ['srcsvc','src','dstsvc','dst'],
2774               [],
2775               $p,
2776             );
2777 }
2778
2779 sub _provision {
2780   my( $class, $fields, $return_fields, $p ) = splice(@_, 0, 4);
2781   warn "_provision called for $class\n"
2782     if $DEBUG;
2783
2784   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2785   return { 'error' => $session } if $context eq 'error';
2786
2787   my $search = { 'custnum' => $custnum };
2788   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2789   my $cust_main = qsearchs('cust_main', $search )
2790     or return { 'error' => "unknown custnum $custnum" };
2791
2792   my $pkgnum = $p->{'pkgnum'};
2793
2794   warn "searching for custnum $custnum pkgnum $pkgnum\n"
2795     if $DEBUG;
2796   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
2797                                         'pkgnum'  => $pkgnum,
2798                                                                } )
2799     or return { 'error' => "unknown pkgnum $pkgnum" };
2800
2801   warn "searching for svcpart ". $p->{'svcpart'}. "\n"
2802     if $DEBUG;
2803   my $part_svc = qsearchs('part_svc', { 'svcpart' => $p->{'svcpart'} } )
2804     or return { 'error' => "unknown svcpart $p->{'svcpart'}" };
2805
2806   return { error=> 'svcpart '. $p->{'svcpart'}. " is not a $class definition" }
2807     if $class ne 'FS::'. $part_svc->svcdb;
2808
2809   warn "creating $class record\n"
2810     if $DEBUG;
2811   my $svc_x = $class->new( {
2812     'pkgnum'  => $p->{'pkgnum'},
2813     'svcpart' => $p->{'svcpart'},
2814     map { $_ => $p->{$_} } @$fields
2815   } );
2816
2817   my %insert_args = ();
2818   #i shouldn't be a special case here (pass an option or something)
2819   if ( $class eq 'FS::svc_phone'
2820          && grep length($p->{$_}), @location_editable_fields
2821      )
2822   {
2823     $insert_args{'cust_location'} = new FS::cust_location {
2824       map { $_ => $p->{$_} } @location_editable_fields
2825     };
2826   }
2827
2828   warn "inserting $class record\n"
2829     if $DEBUG;
2830   my $error = $svc_x->insert(%insert_args);
2831
2832   unless ( $error ) {
2833     warn "finding inserted record for svcnum ". $svc_x->svcnum. "\n"
2834       if $DEBUG;
2835     $svc_x = qsearchs($svc_x->table, { 'svcnum' => $svc_x->svcnum })
2836   }
2837
2838   my $return = { 'svc'   => $part_svc->svc,
2839                  'error' => $error,
2840                  map { $_ => $svc_x->get($_) } @$return_fields
2841                };
2842   warn "_provision returning ". Dumper($return). "\n"
2843     if $DEBUG;
2844   return $return;
2845
2846 }
2847
2848 sub part_svc_info {
2849   my $p = shift;
2850
2851   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2852   return { 'error' => $session } if $context eq 'error';
2853
2854   my $search = { 'custnum' => $custnum };
2855   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2856   my $cust_main = qsearchs('cust_main', $search )
2857     or return { 'error' => "unknown custnum $custnum" };
2858
2859   my $pkgnum = $p->{'pkgnum'};
2860
2861   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
2862                                         'pkgnum'  => $pkgnum,
2863                                                                } )
2864     or return { 'error' => "unknown pkgnum $pkgnum" };
2865
2866   my $svcpart = $p->{'svcpart'};
2867
2868   my $pkg_svc = qsearchs('pkg_svc', { 'pkgpart' => $cust_pkg->pkgpart,
2869                                       'svcpart' => $svcpart,           } )
2870     or return { 'error' => "unknown svcpart $svcpart for pkgnum $pkgnum" };
2871   my $part_svc = $pkg_svc->part_svc;
2872
2873   my $conf = new FS::Conf;
2874
2875   my $ret = {
2876     'svc'     => $part_svc->svc,
2877     'svcdb'   => $part_svc->svcdb,
2878     'pkgnum'  => $pkgnum,
2879     'svcpart' => $svcpart,
2880     'custnum' => $custnum,
2881
2882     'security_phrase' => 0, #XXX !
2883     'svc_acct_pop'    => [], #XXX !
2884     'popnum'          => '',
2885     'init_popstate'   => '',
2886     'popac'           => '',
2887     'acstate'         => '',
2888
2889     'small_custview' =>
2890       small_custview( $cust_main, $conf->config('countrydefault') ),
2891
2892   };
2893
2894   if ($p->{'svcnum'} && $p->{'svcnum'} =~ /^\d+$/ 
2895                      && $ret->{'svcdb'} eq 'svc_phone') {
2896         $ret->{'svcnum'} = $p->{'svcnum'};
2897         my $svc_phone = qsearchs('svc_phone', { svcnum => $p->{'svcnum'} });
2898         if ( $svc_phone && $svc_phone->cust_svc->cust_pkg->custnum == $custnum ) {
2899             $ret->{'email'} = $svc_phone->email;
2900             $ret->{'forwarddst'} = $svc_phone->forwarddst;
2901         }
2902   }
2903
2904   if ($ret->{'svcdb'} eq 'svc_forward') {
2905     $ret->{'forward_emails'} = {$cust_pkg->forward_emails()};
2906   }
2907
2908   $ret;
2909 }
2910
2911 sub unprovision_svc {
2912   my $p = shift;
2913
2914   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2915   return { 'error' => $session } if $context eq 'error';
2916
2917   my $search = { 'custnum' => $custnum };
2918   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2919   my $cust_main = qsearchs('cust_main', $search )
2920     or return { 'error' => "unknown custnum $custnum" };
2921
2922   my $svcnum = $p->{'svcnum'};
2923
2924   my $cust_svc = qsearchs('cust_svc', { 'svcnum'  => $svcnum, } )
2925     or return { 'error' => "unknown svcnum $svcnum" };
2926
2927   return { 'error' => "Service $svcnum does not belong to customer $custnum" }
2928     unless $cust_svc->cust_pkg->custnum == $custnum;
2929
2930   my $conf = new FS::Conf;
2931
2932   return { 'svc'   => $cust_svc->part_svc->svc,
2933            'error' => $cust_svc->cancel,
2934            'small_custview' =>
2935              small_custview( $cust_main, $conf->config('countrydefault') ),
2936          };
2937
2938 }
2939
2940 sub myaccount_passwd {
2941   my $p = shift;
2942   my($context, $session, $custnum) = _custoragent_session_custnum($p);
2943   return { 'error' => $session } if $context eq 'error';
2944
2945   return { 'error' => "New passwords don't match." }
2946     if $p->{'new_password'} ne $p->{'new_password2'};
2947
2948   return { 'error' => 'Enter new password' }
2949     unless length($p->{'new_password'});
2950
2951   #my $search = { 'custnum' => $custnum };
2952   #$search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
2953   $custnum =~ /^(\d+)$/ or die "illegal custnum";
2954   my $search = " AND custnum = $1";
2955   $search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent';
2956
2957   my $svc_acct = qsearchs( {
2958     'table'     => 'svc_acct',
2959     'addl_from' => 'LEFT JOIN cust_svc  USING ( svcnum  ) '.
2960                    'LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
2961                    'LEFT JOIN cust_main USING ( custnum ) ',
2962     'hashref'   => { 'svcnum' => $p->{'svcnum'}, },
2963     'extra_sql' => $search, #important
2964   } )
2965     or return { 'error' => "Service not found" };
2966
2967   my $error = '';
2968
2969   my $conf = new FS::Conf;
2970
2971   return { 'error' => 'Incorrect current password.' }
2972     if  ( exists($p->{'old_password'})
2973           || $conf->exists('selfservice-password_change_oldpass')
2974         )
2975     && ! $svc_acct->check_password($p->{'old_password'});
2976
2977   $error = 'Password too short.'
2978     if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6);
2979   $error = 'Password too long.'
2980     if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8);
2981
2982   $svc_acct->set_password($p->{'new_password'});
2983   $error ||= $svc_acct->replace();
2984
2985   #regular pw change in self-service should change contact pw too, otherwise its
2986   #way too confusing.  hell its confusing they're separate at all, but alas.
2987   #need to support the "ISP provides email that's used as a contact email" case
2988   #as well as we can.
2989   my $contact = FS::contact->by_selfservice_email($svc_acct->email);
2990   if ( $contact && qsearchs('cust_contact', { contactnum=> $contact->contactnum,
2991                                               custnum   => $custnum,
2992                                               selfservice_access => 'Y',
2993                                             }
2994                            )
2995   ) {
2996     #svc_acct was successful but this one returns an error?  "shouldn't happen"
2997     $error ||= $contact->change_password($p->{'new_password'});
2998   }
2999
3000   my($label, $value) = $svc_acct->cust_svc->label;
3001
3002   return { 'error' => $error,
3003            'label' => $label,
3004            'value' => $value,
3005          };
3006
3007 }
3008
3009 sub reset_passwd {
3010   my $p = shift;
3011
3012   my $info = skin_info($p);
3013
3014   my $conf = new FS::Conf;
3015   my $verification = $conf->config('selfservice-password_reset_verification')
3016     or return { %$info, 'error' => 'Password resets disabled' };
3017
3018   my $contact = '';
3019   my $svc_acct = '';
3020   my $cust_main = '';
3021   if ( $p->{'email'} ) { #new-style, changes contact and svc_acct
3022   
3023     $contact = FS::contact->by_selfservice_email($p->{'email'});
3024
3025     if ( $contact ) {
3026       my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
3027       $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1;
3028     }
3029
3030     #also look for an svc_acct, otherwise it would be super confusing
3031
3032     my($username, $domain) = split('@', $p->{'email'});
3033     my $svc_domain = qsearchs('svc_domain', { 'domain' => $domain } );
3034     if ( $svc_domain ) {
3035       $svc_acct = qsearchs('svc_acct', { 'username' => $p->{'username'},
3036                                          'domsvc'   => $svc_domain->svcnum  }
3037                           );
3038       if ( $svc_acct ) {
3039         my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
3040         $cust_main ||= $cust_pkg->cust_main if $cust_pkg;
3041
3042         #precaution: don't change svc_acct password not part of the same
3043         # customer as contact
3044         $svc_acct = '' if ! $cust_pkg
3045                        || $cust_pkg->custnum != $cust_main->custnum;
3046       }
3047       
3048     }
3049
3050     return { %$info, 'error' => 'Email address not found' }
3051       unless $contact || $svc_acct;
3052
3053   } elsif ( $p->{'username'} ) { #old style, looks in svc_acct only
3054
3055     my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
3056       or return { %$info, 'error' => 'Account not found' };
3057
3058     $svc_acct = qsearchs('svc_acct', { 'username' => $p->{'username'},
3059                                        'domsvc'   => $svc_domain->svcnum  }
3060                         )
3061       or return { %$info, 'error' => 'Account not found' };
3062
3063     my $cust_pkg = $svc_acct->cust_svc->cust_pkg
3064       or return { %$info, 'error' => 'Account not found' };
3065
3066     $cust_main = $cust_pkg->cust_main;
3067
3068   }
3069
3070   return { %$info, 'error' => 'Multi-customer contacts incompatible with customer-based verification' }
3071     if ! $cust_main && $verification ne 'email';
3072
3073   my %verify = (
3074     'email'   => sub { 1; },
3075     'paymask' => sub { 
3076       my( $p, $cust_main ) = @_;
3077       $cust_main->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/
3078         && $p->{'paymask'} eq substr($cust_main->paymask, -4)
3079     },
3080     'amount'  => sub {
3081       my( $p, $cust_main ) = @_;
3082       my $cust_pay = qsearchs({
3083         'table' => 'cust_pay',
3084         'hashref' => { 'custnum' => $cust_main->custnum },
3085         'order_by' => 'ORDER BY _date DESC LIMIT 1',
3086       })
3087         or return 0;
3088
3089       $p->{'amount'} == $cust_pay->paid;
3090     },
3091     'zip'     => sub {
3092       my( $p, $cust_main ) = @_;
3093       $p->{'zip'} eq $cust_main->zip
3094         || ( $cust_main->ship_zip && $p->{'zip'} eq $cust_main->ship_zip );
3095     },
3096   );
3097
3098   foreach my $verify ( split(',', $verification) ) {
3099
3100     &{ $verify{$verify} }( $p, $cust_main )
3101       or return { %$info, 'error' => 'Account not found' };
3102
3103   }
3104
3105   #okay, we're verified
3106
3107   if ( $contact ) {
3108
3109     my $error = $contact->send_reset_email(
3110                             'svcnum' => ($svc_acct ? $svc_acct->svcnum : ''),
3111                           );
3112
3113     if ( $error ) {
3114       return { %$info, 'error' => $error }; #????
3115     }
3116
3117   } elsif ( $svc_acct ) {
3118
3119     #create a unique session
3120
3121     my $reset_session = {
3122       'svcnum'   => $svc_acct->svcnum,
3123       'agentnum' =>
3124     };
3125
3126     my $timeout = '1 hour'; #?
3127
3128     my $reset_session_id;
3129     do {
3130       $reset_session_id = sha512_hex(time(). {}. rand(). $$)
3131     } until ( ! defined _cache->get("reset_passwd_$reset_session_id") );
3132       #just in case
3133
3134     _cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout );
3135
3136     #email it
3137
3138     my $msgnum = $conf->config('selfservice-password_reset_msgnum',
3139                                $cust_main->agentnum);
3140     #die "selfservice-password_reset_msgnum unset" unless $msgnum;
3141     return { %$info, 'error' => "selfservice-password_reset_msgnum unset" }
3142       unless $msgnum;
3143     my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
3144     my $error = $msg_template->send( 'cust_main'     => $cust_main,
3145                                      'object'        => $svc_acct,
3146                                      'substitutions' => {
3147                                        'session_id' => $reset_session_id,
3148                                      }
3149                                    );
3150     if ( $error ) {
3151       return { %$info, 'error' => $error }; #????
3152     }
3153
3154   }
3155
3156   return { %$info, 'error' => '' };
3157 }
3158
3159 sub check_reset_passwd {
3160   my $p = shift;
3161
3162   my $conf = new FS::Conf;
3163   my $verification = $conf->config('selfservice-password_reset_verification')
3164     or return { 'error' => 'Password resets disabled' };
3165
3166   my $reset_session = _cache->get('reset_passwd_'. $p->{'session_id'})
3167     or return { 'error' => "Can't resume session" }; #better error message
3168
3169   if ( $reset_session->{'svcnum'} ) {
3170
3171     my $svcnum = $reset_session->{'svcnum'};
3172
3173     my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } )
3174       or return { 'error' => "Service not found" };
3175
3176     $p->{'agentnum'} = $svc_acct->cust_svc->cust_pkg->cust_main->agentnum;
3177     my $info = skin_info($p);
3178
3179     return { %$info,
3180              'error'      => '',
3181              'session_id' => $p->{'session_id'},
3182              'username'   => $svc_acct->username,
3183            };
3184
3185   } elsif ( $reset_session->{'contactnum'} ) {
3186
3187     my $contactnum = $reset_session->{'contactnum'};
3188
3189     my $contact = qsearchs('contact', { 'contactnum' => $contactnum } )
3190       or return { 'error' => "Contact not found" };
3191
3192     my @contact_email = $contact->contact_email;
3193     return { 'error' => 'No contact email' } unless @contact_email;
3194
3195     my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
3196     $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum
3197       if scalar(@cust_contact) == 1;
3198     my $info = skin_info($p);
3199
3200     return { %$info,
3201              'error'      => '',
3202              'session_id' => $p->{'session_id'},
3203              'email'      => $contact_email[0]->email, #the first?
3204            };
3205
3206   } else {
3207
3208     return { 'error' => 'No svcnum or contactnum in session' }; #??
3209
3210   }
3211
3212 }
3213
3214 sub process_reset_passwd {
3215   my $p = shift;
3216
3217   my $conf = new FS::Conf;
3218   my $verification = $conf->config('selfservice-password_reset_verification')
3219     or return { 'error' => 'Password resets disabled' };
3220
3221   my $reset_session = _cache->get('reset_passwd_'. $p->{'session_id'})
3222     or return { 'error' => "Can't resume session" }; #better error message
3223
3224   my $info = '';
3225
3226   my $svc_acct = '';
3227   if ( $reset_session->{'svcnum'} ) {
3228
3229     my $svcnum = $reset_session->{'svcnum'};
3230
3231     $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } )
3232       or return { 'error' => "Service not found" };
3233
3234     $p->{'agentnum'} ||= $svc_acct->cust_svc->cust_pkg->cust_main->agentnum;
3235     $info ||= skin_info($p);
3236
3237   }
3238
3239   my $contact = '';
3240   if ( $reset_session->{'contactnum'} ) {
3241
3242     my $contactnum = $reset_session->{'contactnum'};
3243
3244     $contact = qsearchs('contact', { 'contactnum' => $contactnum } )
3245       or return { 'error' => "Contact not found" };
3246
3247     my @cust_contact = grep $_->selfservice_access, $contact->cust_contact;
3248     $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum
3249       if scalar(@cust_contact) == 1;
3250     $info ||= skin_info($p);
3251
3252   }
3253
3254   return { %$info, 'error' => "New passwords don't match." }
3255     if $p->{'new_password'} ne $p->{'new_password2'};
3256
3257   return { %$info, 'error' => 'Enter new password' }
3258     unless length($p->{'new_password'});
3259
3260   if ( $svc_acct ) {
3261
3262     $svc_acct->set_password($p->{'new_password'});
3263     my $error = $svc_acct->replace();
3264
3265     return { %$info, 'error' => $error } if $error;
3266
3267     #my($label, $value) = $svc_acct->cust_svc->label;
3268     #return { 'error' => $error,
3269     #         #'label' => $label,
3270     #         #'value' => $value,
3271     #       };
3272
3273   }
3274
3275   if ( $contact ) {
3276
3277     my $error = $contact->change_password($p->{'new_password'});
3278
3279     return { %$info, 'error' => $error }; # if $error;
3280
3281   }
3282
3283   #password changed ,so remove session, don't want it reused
3284   _cache->remove($p->{'session_id'});
3285
3286   return { %$info, 'error' => '' };
3287
3288 }
3289
3290 sub list_tickets {
3291   my $p = shift;
3292   my($context, $session, $custnum) = _custoragent_session_custnum($p);
3293   return { 'error' => $session } if $context eq 'error';
3294
3295   my @tickets = ();
3296   if ( $session->{'pkgnum'} ) { 
3297
3298     #tickets for specific service with pkg-balances on
3299     my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
3300                                           'pkgnum'  => $session->{'pkgnum'} })
3301                      or return { 'error' => 'unknown package' };
3302     foreach my $cust_svc ( $cust_pkg->cust_svc ) {
3303       push @tickets, $cust_svc->tickets( $p->{status} );
3304     }
3305
3306   } else {
3307
3308     my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
3309       or return { 'error' => "unknown custnum $custnum" };
3310
3311     @tickets = $cust_main->tickets( $p->{status} );
3312   }
3313
3314   # unavoidable false laziness w/ httemplate/view/cust_main/tickets.html
3315   if ( $FS::TicketSystem::system && FS::TicketSystem->selfservice_priority ) {
3316     my $conf = new FS::Conf;
3317     my $dir = $conf->exists('ticket_system-priority_reverse') ? -1 : 1;
3318     +{ tickets => [ 
3319          sort { 
3320            (
3321              ($a->{'_selfservice_priority'} eq '') <=>
3322              ($b->{'_selfservice_priority'} eq '')
3323            ) ||
3324            ( $dir * 
3325              ($b->{'_selfservice_priority'} <=> $a->{'_selfservice_priority'})
3326            )
3327          } @tickets
3328        ]
3329     };
3330   } else {
3331     +{ tickets => \@tickets };
3332   }
3333
3334 }
3335
3336 sub create_ticket {
3337   my $p = shift;
3338   my($context, $session, $custnum) = _custoragent_session_custnum($p);
3339   return { 'error' => $session } if $context eq 'error';
3340
3341   warn "$me create_ticket: initializing ticket system\n" if $DEBUG;
3342   FS::TicketSystem->init();
3343
3344   my $conf = new FS::Conf;
3345   my $queue = $p->{'queue'}
3346               || $conf->config('ticket_system-selfservice_queueid')
3347               || $conf->config('ticket_system-default_queueid');
3348
3349   warn "$me create_ticket: creating ticket\n" if $DEBUG;
3350   my $err_or_ticket = FS::TicketSystem->create_ticket(
3351     '', #create RT session based on FS CurrentUser (fs_selfservice)
3352     'queue'   => $queue,
3353     'custnum' => $custnum,
3354     'svcnum'  => $session->{'svcnum'},
3355     map { $_ => $p->{$_} } qw( requestor cc subject message mime_type )
3356   );
3357
3358   if ( ref($err_or_ticket) ) {
3359     warn "$me create_ticket: successful: ". $err_or_ticket->id. "\n"
3360       if $DEBUG;
3361     return { 'error'     => '',
3362              'ticket_id' => $err_or_ticket->id,
3363            };
3364   } else {
3365     warn "$me create_ticket: unsuccessful: $err_or_ticket\n"
3366       if $DEBUG;
3367     return { 'error' => $err_or_ticket };
3368   }
3369
3370
3371 }
3372
3373 sub did_report {
3374   my $p = shift;
3375   my($context, $session, $custnum) = _custoragent_session_custnum($p);
3376   return { 'error' => $session } if $context eq 'error';
3377  
3378   return { error => 'requested format not implemented' } 
3379     unless ($p->{'format'} eq 'csv' || $p->{'format'} eq 'xls');
3380
3381   my $conf = new FS::Conf;
3382   my $age_threshold = 0;
3383   $age_threshold = time() - $conf->config('selfservice-recent-did-age')
3384     if ($p->{'recentonly'} && $conf->exists('selfservice-recent-did-age'));
3385
3386   my $search = { 'custnum' => $custnum };
3387   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
3388   my $cust_main = qsearchs('cust_main', $search )
3389     or return { 'error' => "unknown custnum $custnum" };
3390
3391 # does it make more sense to just run one sql query for this instead of all the
3392 # insanity below? would increase performance greately for large data sets?
3393   my @svc_phone = ();
3394   foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) {
3395         my @part_svc = $cust_pkg->part_svc;
3396         foreach my $part_svc ( @part_svc ) {
3397             if($part_svc->svcdb eq 'svc_phone'){
3398                 my @cust_pkg_svc = @{$part_svc->cust_pkg_svc};
3399                 foreach my $cust_pkg_svc ( @cust_pkg_svc ) {
3400                     push @svc_phone, $cust_pkg_svc->svc_x
3401                         if $cust_pkg_svc->date_inserted >= $age_threshold;
3402                 }
3403             }
3404         }
3405   }
3406
3407   my $csv;
3408   my $xls;
3409   my($xls_r,$xls_c) = (0,0);
3410   my $xls_workbook;
3411   my $content = '';
3412   my @fields = qw( countrycode phonenum pin sip_password phone_name );
3413   if($p->{'format'} eq 'csv') {
3414     $csv = new Text::CSV_XS { 'always_quote' => 1,
3415                                  'eol'          => "\n",
3416                                 };
3417     return { 'error' => 'Unable to create CSV' } unless $csv->combine(@fields);
3418     $content .= $csv->string;
3419   }
3420   elsif($p->{'format'} eq 'xls') {
3421     my $XLS1 = new IO::Scalar \$content;
3422     $xls_workbook = Spreadsheet::WriteExcel->new($XLS1) 
3423         or return { 'error' => "Error opening .xls file: $!" };
3424     $xls = $xls_workbook->add_worksheet('DIDs');
3425     foreach ( @fields ) {
3426         $xls->write(0,$xls_c++,$_);
3427     }
3428     $xls_r++;
3429   }
3430
3431   foreach my $svc_phone ( @svc_phone ) {
3432     my @cols = map { $svc_phone->$_ } @fields;
3433     if($p->{'format'} eq 'csv') {
3434         return { 'error' => 'Unable to create CSV' } 
3435             unless $csv->combine(@cols);
3436         $content .= $csv->string;
3437     }
3438     elsif($p->{'format'} eq 'xls') {
3439         $xls_c = 0;
3440         foreach ( @cols ) {
3441             $xls->write($xls_r,$xls_c++,$_);
3442         }
3443         $xls_r++;
3444     }
3445   }
3446
3447   $xls_workbook->close() if $p->{'format'} eq 'xls';
3448   
3449   { content => $content, format => $p->{'format'}, };
3450 }
3451
3452 sub get_ticket {
3453   my $p = shift;
3454   my($context, $session, $custnum) = _custoragent_session_custnum($p);
3455   return { 'error' => $session } if $context eq 'error';
3456
3457 #  warn "$me get_ticket: initializing ticket system\n" if $DEBUG;
3458 #  FS::TicketSystem->init();
3459 #  return { 'error' => 'get_ticket configuration error' }
3460 #    if $FS::TicketSystem::system ne 'RT_Internal';
3461
3462   # check existence and ownership as part of this
3463   warn "$me get_ticket: fetching ticket\n" if $DEBUG;
3464   my $rt_session = FS::TicketSystem->session('');
3465   my $Ticket = FS::TicketSystem->get_ticket_object(
3466     $rt_session, 
3467     ticket_id => $p->{'ticket_id'},
3468     custnum => $custnum
3469   );
3470   return { 'error' => 'ticket not found' } if !$Ticket;
3471
3472   if ( length( $p->{'subject'} || '' ) ) {
3473     # subject change
3474     if ( $p->{'subject'} ne $Ticket->Subject ) {
3475       my ($val, $msg) = $Ticket->SetSubject($p->{'subject'});
3476       return { 'error' => "unable to set subject: $msg" } if !$val;
3477     }
3478   }
3479
3480   if(length($p->{'reply'})) {
3481     my @err_or_res = FS::TicketSystem->correspond_ticket(
3482       $rt_session,
3483       'ticket_id' => $p->{'ticket_id'},
3484       'content' => $p->{'reply'},
3485     );
3486
3487     return { 'error' => 'unable to reply to ticket' } 
3488     unless ( $err_or_res[0] != 0 && defined $err_or_res[2] );
3489   }
3490
3491   warn "$me get_ticket: getting ticket history\n" if $DEBUG;
3492   my $err_or_ticket = FS::TicketSystem->get_ticket(
3493     $rt_session,
3494     'ticket_id' => $p->{'ticket_id'},
3495   );
3496
3497   if ( !ref($err_or_ticket) ) { # there is no way this should ever happen
3498     warn "$me get_ticket: unsuccessful: $err_or_ticket\n"
3499       if $DEBUG;
3500     return { 'error' => $err_or_ticket };
3501   }
3502
3503   my @custs = @{$err_or_ticket->{'custs'}};
3504   my @txns = @{$err_or_ticket->{'txns'}};
3505   my @filtered_txns;
3506
3507   # superseded by check in get_ticket_object
3508   #return { 'error' => 'invalid ticket requested' } 
3509   #unless grep($_ eq $custnum, @custs);
3510
3511   foreach my $txn ( @txns ) {
3512     push @filtered_txns, $txn 
3513     if ($txn->{'type'} eq 'EmailRecord' 
3514       || $txn->{'type'} eq 'Correspond'
3515       || $txn->{'type'} eq 'Create');
3516   }
3517
3518   warn "$me get_ticket: successful: \n"
3519   if $DEBUG;
3520   return { 'error'     => '',
3521     'transactions' => \@filtered_txns,
3522     'ticket_fields' => $err_or_ticket->{'fields'},
3523     'ticket_id' => $p->{'ticket_id'},
3524   };
3525 }
3526
3527 sub adjust_ticket_priority {
3528   my $p = shift;
3529   my($context, $session, $custnum) = _custoragent_session_custnum($p);
3530   return { 'error' => $session } if $context eq 'error';
3531
3532 #  warn "$me adjust_ticket_priority: initializing ticket system\n" if $DEBUG;
3533 #  FS::TicketSystem->init;
3534   my $ss_priority = FS::TicketSystem->selfservice_priority;
3535
3536   return { 'error' => 'adjust_ticket_priority configuration error' }
3537     if $FS::TicketSystem::system ne 'RT_Internal'
3538       or !$ss_priority;
3539
3540   my $values = $p->{'values'}; #hashref, id => priority value
3541   my %ticket_error;
3542
3543   foreach my $id (keys %$values) {
3544     warn "$me adjust_ticket_priority: fetching ticket $id\n" if $DEBUG;
3545     my $Ticket = FS::TicketSystem->get_ticket_object('',
3546       'ticket_id' => $id,
3547       'custnum'   => $custnum,
3548     );
3549     if ( !$Ticket ) {
3550       $ticket_error{$id} = 'ticket not found';
3551       next;
3552     }
3553     
3554   # RT API stuff--would we gain anything by wrapping this in FS::TicketSystem?
3555   # We're not going to implement it for RT_External.
3556     my $old_value = $Ticket->FirstCustomFieldValue($ss_priority);
3557     my $new_value = $values->{$id};
3558     next if $old_value eq $new_value;
3559
3560     warn "$me adjust_ticket_priority: updating ticket $id\n" if $DEBUG;
3561
3562     # AddCustomFieldValue works fine (replacing any existing value) if it's 
3563     # a single-valued custom field, which it should be.  If it's not, you're 
3564     # doing something wrong.
3565     my ($val, $msg);
3566     if ( length($new_value) ) {
3567       ($val, $msg) = $Ticket->AddCustomFieldValue( 
3568         Field => $ss_priority,
3569         Value => $new_value,
3570       );
3571     }
3572     else {
3573       ($val, $msg) = $Ticket->DeleteCustomFieldValue(
3574         Field => $ss_priority,
3575         Value => $old_value,
3576       );
3577     }
3578
3579     $ticket_error{$id} = $msg if !$val;
3580     warn "$me adjust_ticket_priority: $id: $msg\n" if $DEBUG and !$val;
3581   }
3582   return { 'error' => '',
3583            'ticket_error' => \%ticket_error,
3584            %{ customer_info($p) } # send updated customer info back
3585          }
3586 }
3587
3588 #--
3589
3590 sub _custoragent_session_custnum {
3591   my $p = shift;
3592
3593   my($context, $session, $custnum);
3594   if ( $p->{'session_id'} ) {
3595
3596     $context = 'customer';
3597     $session = _cache->get($p->{'session_id'})
3598       or return ( 'error' => "Can't resume session" ); #better error message
3599     $custnum = $session->{'custnum'};
3600
3601   } elsif ( $p->{'agent_session_id'} ) {
3602
3603     $context = 'agent';
3604     my $agent_cache = new FS::ClientAPI_SessionCache( {
3605       'namespace' => 'FS::ClientAPI::Agent',
3606     } );
3607     $session = $agent_cache->get($p->{'agent_session_id'})
3608       or return ( 'error' => "Can't resume session" ); #better error message
3609     $custnum = $p->{'custnum'};
3610
3611   } else {
3612     $context = 'error';
3613     return ( 'error' => "Can't resume session" ); #better error message
3614   }
3615
3616   ($context, $session, $custnum);
3617
3618 }
3619
3620 1;
3621