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