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