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