tyop;
[freeside.git] / FS / FS / ClientAPI / MyAccount.pm
1 package FS::ClientAPI::MyAccount;
2
3 use strict;
4 use vars qw($cache);
5 use Digest::MD5 qw(md5_hex);
6 use Date::Format;
7 use Business::CreditCard;
8 use Cache::SharedMemoryCache; #store in db?
9 use FS::CGI qw(small_custview); #doh
10 use FS::Conf;
11 use FS::Record qw(qsearch qsearchs);
12 use FS::Msgcat qw(gettext);
13 use FS::svc_acct;
14 use FS::svc_domain;
15 use FS::svc_external;
16 use FS::part_svc;
17 use FS::cust_main;
18 use FS::cust_bill;
19 use FS::cust_main_county;
20 use FS::cust_pkg;
21
22 use FS::ClientAPI; #hmm
23 FS::ClientAPI->register_handlers(
24   'MyAccount/login'            => \&login,
25   'MyAccount/customer_info'    => \&customer_info,
26   'MyAccount/edit_info'        => \&edit_info,
27   'MyAccount/invoice'          => \&invoice,
28   'MyAccount/list_invoices'    => \&list_invoices,
29   'MyAccount/cancel'           => \&cancel,
30   'MyAccount/payment_info'     => \&payment_info,
31   'MyAccount/process_payment'  => \&process_payment,
32   'MyAccount/list_pkgs'        => \&list_pkgs,
33   'MyAccount/order_pkg'        => \&order_pkg,
34   'MyAccount/cancel_pkg'       => \&cancel_pkg,
35   'MyAccount/charge'           => \&charge,
36 );
37
38 use vars qw( @cust_main_editable_fields );
39 @cust_main_editable_fields = qw(
40   first last company address1 address2 city
41     county state zip country daytime night fax
42   ship_first ship_last ship_company ship_address1 ship_address2 ship_city
43     ship_state ship_zip ship_country ship_daytime ship_night ship_fax
44   payby payinfo payname
45 );
46
47 #store in db?
48 my $cache = new Cache::SharedMemoryCache( {
49    'namespace' => 'FS::ClientAPI::MyAccount',
50 } );
51
52 #false laziness w/FS::ClientAPI::passwd::passwd
53 sub login {
54   my $p = shift;
55
56   my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
57     or return { error => 'Domain '. $p->{'domain'}. ' not found' };
58
59   my $svc_acct = qsearchs( 'svc_acct', { 'username'  => $p->{'username'},
60                                          'domsvc'    => $svc_domain->svcnum, }
61                          );
62   return { error => 'User not found.' } unless $svc_acct;
63
64   my $conf = new FS::Conf;
65   my $pkg_svc = $svc_acct->cust_svc->pkg_svc;
66   return { error => 'Only primary user may log in.' } 
67     if $conf->exists('selfservice_server-primary_only')
68        && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' );
69
70   return { error => 'Incorrect password.' }
71     unless $svc_acct->check_password($p->{'password'});
72
73   my $session = {
74     'svcnum' => $svc_acct->svcnum,
75   };
76
77   my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
78   if ( $cust_pkg ) {
79     my $cust_main = $cust_pkg->cust_main;
80     $session->{'custnum'} = $cust_main->custnum;
81   }
82
83   my $session_id;
84   do {
85     $session_id = md5_hex(md5_hex(time(). {}. rand(). $$))
86   } until ( ! defined $cache->get($session_id) ); #just in case
87
88   $cache->set( $session_id, $session, '1 hour' );
89
90   return { 'error'      => '',
91            'session_id' => $session_id,
92          };
93 }
94
95 sub customer_info {
96   my $p = shift;
97
98   my($session, $custnum, $context);
99   if ( $p->{'session_id'} ) {
100     $context = 'customer';
101     $session = $cache->get($p->{'session_id'})
102       or return { 'error' => "Can't resume session" }; #better error message
103     $custnum = $session->{'custnum'};
104   } elsif ( $p->{'agent_session_id'} ) {
105     $context = 'agent';
106     my $agent_cache = new Cache::SharedMemoryCache( {
107       'namespace' => 'FS::ClientAPI::Agent',
108     } );
109     $session = $agent_cache->get($p->{'agent_session_id'})
110       or return { 'error' => "Can't resume session" }; #better error message
111     $custnum = $p->{'custnum'};
112   } else {
113     return { 'error' => "Can't resume session" }; #better error message
114   }
115
116   my %return;
117   if ( $custnum ) { #customer record
118
119     my $search = { 'custnum' => $custnum };
120     $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
121     my $cust_main = qsearchs('cust_main', $search )
122       or return { 'error' => "unknown custnum $custnum" };
123
124     $return{balance} = $cust_main->balance;
125
126     my @open = map {
127                      {
128                        invnum => $_->invnum,
129                        date   => time2str("%b %o, %Y", $_->_date),
130                        owed   => $_->owed,
131                      };
132                    } $cust_main->open_cust_bill;
133     $return{open_invoices} = \@open;
134
135     my $conf = new FS::Conf;
136     $return{small_custview} =
137       small_custview( $cust_main, $conf->config('defaultcountry') );
138
139     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
140
141     for (@cust_main_editable_fields) {
142       $return{$_} = $cust_main->get($_);
143     }
144
145     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
146       $return{payinfo} = $cust_main->payinfo_masked;
147       @return{'month', 'year'} = $cust_main->paydate_monthyear;
148     }
149
150     $return{'invoicing_list'} =
151       join(', ', grep { $_ ne 'POST' } $cust_main->invoicing_list );
152     $return{'postal_invoicing'} =
153       0 < ( grep { $_ eq 'POST' } $cust_main->invoicing_list );
154
155   } else { #no customer record
156
157     my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $session->{'svcnum'} } )
158       or die "unknown svcnum";
159     $return{name} = $svc_acct->email;
160
161   }
162
163   return { 'error'          => '',
164            'custnum'        => $custnum,
165            %return,
166          };
167
168 }
169
170 sub edit_info {
171   my $p = shift;
172   my $session = $cache->get($p->{'session_id'})
173     or return { 'error' => "Can't resume session" }; #better error message
174
175   my $custnum = $session->{'custnum'}
176     or return { 'error' => "no customer record" };
177
178   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
179     or return { 'error' => "unknown custnum $custnum" };
180
181   my $new = new FS::cust_main { $cust_main->hash };
182   $new->set( $_ => $p->{$_} )
183     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
184
185   if ( $p->{'payby'} =~ /^(CARD|DCRD)$/ ) {
186     $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01');
187     if ( $new->payinfo eq $cust_main->payinfo_masked ) {
188       $new->payinfo($cust_main->payinfo);
189     } else {
190       $new->paycvv($p->{'paycvv'});
191     }
192   }
193
194   my @invoicing_list;
195   if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) {
196     #false laziness with httemplate/edit/process/cust_main.cgi
197     @invoicing_list = split( /\s*\,\s*/, $p->{'invoicing_list'} );
198     push @invoicing_list, 'POST' if $p->{'postal_invoicing'};
199   } else {
200     @invoicing_list = $cust_main->invoicing_list;
201   }
202
203   my $error = $new->replace($cust_main, \@invoicing_list);
204   return { 'error' => $error } if $error;
205   #$cust_main = $new;
206   
207   return { 'error' => '' };
208 }
209
210 sub payment_info {
211   my $p = shift;
212   my $session = $cache->get($p->{'session_id'})
213     or return { 'error' => "Can't resume session" }; #better error message
214
215   ##
216   #generic
217   ##
218
219   my $conf = new FS::Conf;
220   my %states = map { $_->state => 1 }
221                  qsearch('cust_main_county', {
222                    'country' => $conf->config('defaultcountry') || 'US'
223                  } );
224
225   use vars qw($payment_info); #cache for performance
226   $payment_info ||= {
227
228     #list all counties/states/countries
229     'cust_main_county' => 
230       [ map { $_->hashref } qsearch('cust_main_county', {}) ],
231
232     #shortcut for one-country folks
233     'states' =>
234       [ sort { $a cmp $b } keys %states ],
235
236     'card_types' => {
237       'VISA' => 'VISA card',
238       'MasterCard' => 'MasterCard',
239       'Discover' => 'Discover card',
240       'American Express' => 'American Express card',
241     },
242
243   };
244
245   ##
246   #customer-specific
247   ##
248
249   my %return = %$payment_info;
250
251   my $custnum = $session->{'custnum'};
252
253   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
254     or return { 'error' => "unknown custnum $custnum" };
255
256   $return{balance} = $cust_main->balance;
257
258   $return{payname} = $cust_main->payname
259                      || ( $cust_main->first. ' '. $cust_main->get('last') );
260
261   $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
262
263   $return{payby} = $cust_main->payby;
264
265   if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
266     #warn $return{card_type} = cardtype($cust_main->payinfo);
267     $return{payinfo} = $cust_main->payinfo;
268
269     @return{'month', 'year'} = $cust_main->paydate_monthyear;
270
271   }
272
273   #doubleclick protection
274   my $_date = time;
275   $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
276
277   return { 'error' => '',
278            %return,
279          };
280
281 };
282
283 #some false laziness with httemplate/process/payment.cgi - look there for
284 #ACH and CVV support stuff
285 sub process_payment {
286
287   my $p = shift;
288
289   my $session = $cache->get($p->{'session_id'})
290     or return { 'error' => "Can't resume session" }; #better error message
291
292   my %return;
293
294   my $custnum = $session->{'custnum'};
295
296   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
297     or return { 'error' => "unknown custnum $custnum" };
298
299   $p->{'payname'} =~ /^([\w \,\.\-\']+)$/
300     or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} };
301   my $payname = $1;
302
303   $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
304     or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} };
305   my $paybatch = $1;
306
307   my $payinfo;
308   my $paycvv = '';
309   #if ( $payby eq 'CHEK' ) {
310   #
311   #  $p->{'payinfo1'} =~ /^(\d+)$/
312   #    or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
313   #  my $payinfo1 = $1;
314   #   $p->{'payinfo2'} =~ /^(\d+)$/
315   #    or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
316   #  my $payinfo2 = $1;
317   #  $payinfo = $payinfo1. '@'. $payinfo2;
318   # 
319   #} elsif ( $payby eq 'CARD' ) {
320    
321     $payinfo = $p->{'payinfo'};
322     $payinfo =~ s/\D//g;
323     $payinfo =~ /^(\d{13,16})$/
324       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
325     $payinfo = $1;
326     validate($payinfo)
327       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
328     return { 'error' => gettext('unknown_card_type') }
329       if cardtype($payinfo) eq "Unknown";
330
331     if ( defined $cust_main->dbdef_table->column('paycvv') ) {
332       if ( length($p->{'paycvv'} ) ) {
333         if ( cardtype($payinfo) eq 'American Express card' ) {
334           $p->{'paycvv'} =~ /^(\d{4})$/
335             or return { 'error' => "CVV2 (CID) for American Express cards is four digits." };
336           $paycvv = $1;
337         } else {
338           $p->{'paycvv'} =~ /^(\d{3})$/
339             or return { 'error' => "CVV2 (CVC2/CID) is three digits." };
340           $paycvv = $1;
341         }
342       }
343     }
344   
345   #} else {
346   #  die "unknown payby $payby";
347   #}
348
349   my $error = $cust_main->realtime_bop( 'CC', $p->{'amount'},
350     'quiet'    => 1,
351     'payinfo'  => $payinfo,
352     'paydate'  => $p->{'year'}. '-'. $p->{'month'}. '-01',
353     'payname'  => $payname,
354     'paybatch' => $paybatch,
355     'paycvv'   => $paycvv,
356     map { $_ => $p->{$_} } qw( address1 address2 city state zip )
357   );
358   return { 'error' => $error } if $error;
359
360   $cust_main->apply_payments;
361
362   if ( $p->{'save'} ) {
363     my $new = new FS::cust_main { $cust_main->hash };
364     $new->set( $_ => $p->{$_} )
365       foreach qw( payname address1 address2 city state zip payinfo );
366     $new->set( 'paydate' => $p->{'year'}. '-'. $p->{'month'}. '-01' );
367     $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
368     my $error = $new->replace($cust_main);
369     return { 'error' => $error } if $error;
370     $cust_main = $new;
371   }
372
373   return { 'error' => '' };
374
375 }
376
377 sub invoice {
378   my $p = shift;
379   my $session = $cache->get($p->{'session_id'})
380     or return { 'error' => "Can't resume session" }; #better error message
381
382   my $custnum = $session->{'custnum'};
383
384   my $invnum = $p->{'invnum'};
385
386   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
387                                           'custnum' => $custnum } )
388     or return { 'error' => "Can't find invnum" };
389
390   #my %return;
391
392   return { 'error'        => '',
393            'invnum'       => $invnum,
394            'invoice_text' => join('', $cust_bill->print_text ),
395          };
396
397 }
398
399 sub list_invoices {
400   my $p = shift;
401   my $session = $cache->get($p->{'session_id'})
402     or return { 'error' => "Can't resume session" }; #better error message
403
404   my $custnum = $session->{'custnum'};
405
406   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
407     or return { 'error' => "unknown custnum $custnum" };
408
409   my @cust_bill = $cust_main->cust_bill;
410
411   return  { 'error'       => '',
412             'invoices'    =>  [ map { { 'invnum' => $_->invnum,
413                                         '_date'  => $_->_date,
414                                       }
415                                     } @cust_bill
416                               ]
417           };
418 }
419
420 sub cancel {
421   my $p = shift;
422   my $session = $cache->get($p->{'session_id'})
423     or return { 'error' => "Can't resume session" }; #better error message
424
425   my $custnum = $session->{'custnum'};
426
427   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
428     or return { 'error' => "unknown custnum $custnum" };
429
430   my @errors = $cust_main->cancel( 'quiet'=>1 );
431
432   my $error = scalar(@errors) ? join(' / ', @errors) : '';
433
434   return { 'error' => $error };
435
436 }
437
438 sub list_pkgs {
439   my $p = shift;
440   my $session = $cache->get($p->{'session_id'})
441     or return { 'error' => "Can't resume session" }; #better error message
442
443   my $custnum = $session->{'custnum'};
444
445   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
446     or return { 'error' => "unknown custnum $custnum" };
447
448   return { 'cust_pkg' => [ map { $_->hashref } $cust_main->ncancelled_pkgs ] };
449
450 }
451
452 sub order_pkg {
453   my $p = shift;
454
455   my($session, $custnum, $context);
456
457   if ( $p->{'session_id'} ) {
458     $context = 'customer';
459     $session = $cache->get($p->{'session_id'})
460       or return { 'error' => "Can't resume session" }; #better error message
461     $custnum = $session->{'custnum'};
462   } elsif ( $p->{'agent_session_id'} ) {
463     $context = 'agent';
464     my $agent_cache = new Cache::SharedMemoryCache( {
465       'namespace' => 'FS::ClientAPI::Agent',
466     } );
467     $session = $agent_cache->get($p->{'agent_session_id'})
468       or return { 'error' => "Can't resume session" }; #better error message
469     $custnum = $p->{'custnum'};
470   } else {
471     return { 'error' => "Can't resume session" }; #better error message
472   }
473
474   my $search = { 'custnum' => $custnum };
475   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
476
477   my $cust_main = qsearchs('cust_main', $search )
478     or return { 'error' => "unknown custnum $custnum" };
479
480   #false laziness w/ClientAPI/Signup.pm
481
482   my $cust_pkg = new FS::cust_pkg ( {
483     'custnum' => $custnum,
484     'pkgpart' => $p->{'pkgpart'},
485   } );
486   my $error = $cust_pkg->check;
487   return { 'error' => $error } if $error;
488
489   my @svc = ();
490   unless ( $p->{'svcpart'} eq 'none' ) {
491
492     my $svcdb;
493     my $svcpart = '';
494     if ( $p->{'svcpart'} =~ /^(\d+)$/ ) {
495       $svcpart = $1;
496       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
497       return { 'error' => "Unknown svcpart $svcpart" } unless $part_svc;
498       $svcdb = $part_svc->svcdb;
499     } else {
500       $svcdb = 'svc_acct';
501     }
502     $svcpart ||= $cust_pkg->part_pkg->svcpart($svcdb);
503
504     my %fields = (
505       'svc_acct'     => [ qw( username _password sec_phrase popnum ) ],
506       'svc_domain'   => [ qw( domain ) ],
507       'svc_external' => [ qw( id title ) ],
508     );
509   
510     my $svc_x = "FS::$svcdb"->new( {
511       'svcpart'   => $svcpart,
512       map { $_ => $p->{$_} } @{$fields{$svcdb}}
513     } );
514     
515     if ( $svcdb eq 'svc_acct' ) {
516       my @acct_snarf;
517       my $snarfnum = 1;
518       while ( length($p->{"snarf_machine$snarfnum"}) ) {
519         my $acct_snarf = new FS::acct_snarf ( {
520           'machine'   => $p->{"snarf_machine$snarfnum"},
521           'protocol'  => $p->{"snarf_protocol$snarfnum"},
522           'username'  => $p->{"snarf_username$snarfnum"},
523           '_password' => $p->{"snarf_password$snarfnum"},
524         } );
525         $snarfnum++;
526         push @acct_snarf, $acct_snarf;
527       }
528       $svc_x->child_objects( \@acct_snarf );
529     }
530     
531     my $y = $svc_x->setdefault; # arguably should be in new method
532     return { 'error' => $y } if $y && !ref($y);
533   
534     $error = $svc_x->check;
535     return { 'error' => $error } if $error;
536
537     push @svc, $svc_x;
538
539   }
540
541   use Tie::RefHash;
542   tie my %hash, 'Tie::RefHash';
543   %hash = ( $cust_pkg => \@svc );
544   #msgcat
545   $error = $cust_main->order_pkgs( \%hash, '', 'noexport' => 1 );
546   return { 'error' => $error } if $error;
547
548   my $conf = new FS::Conf;
549   if ( $conf->exists('signup_server-realtime') ) {
550
551     my $old_balance = $cust_main->balance;
552
553     my $bill_error = $cust_main->bill;
554     $cust_main->apply_payments;
555     $cust_main->apply_credits;
556     $bill_error = $cust_main->collect;
557
558     if ( $cust_main->balance > $old_balance
559          && $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/ ) {
560       #this makes sense.  credit is "un-doing" the invoice
561       $cust_main->credit( sprintf("%.2f", $cust_main->balance - $old_balance ),
562                           'self-service decline' );
563       $cust_main->apply_credits( 'order' => 'newest' );
564
565       $cust_pkg->cancel('quiet'=>1);
566       return { 'error' => '_decline' };
567     } else {
568       $cust_pkg->reexport;
569     }
570
571   } else {
572     $cust_pkg->reexport;
573   }
574
575   return { error => '', pkgnum => $cust_pkg->pkgnum };
576
577 }
578
579 sub cancel_pkg {
580   my $p = shift;
581   my $session = $cache->get($p->{'session_id'})
582     or return { 'error' => "Can't resume session" }; #better error message
583
584   my $custnum = $session->{'custnum'};
585
586   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
587     or return { 'error' => "unknown custnum $custnum" };
588
589   my $pkgnum = $p->{'pkgnum'};
590
591   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
592                                         'pkgnum'  => $pkgnum,   } )
593     or return { 'error' => "unknown pkgnum $pkgnum" };
594
595   my $error = $cust_pkg->cancel( 'quiet'=>1 );
596   return { 'error' => $error };
597
598 }
599
600 1;
601