credit out self-service
[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   my %return;
216
217   my $custnum = $session->{'custnum'};
218
219   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
220     or return { 'error' => "unknown custnum $custnum" };
221
222   $return{balance} = $cust_main->balance;
223
224   $return{payname} = $cust_main->payname
225                      || ( $cust_main->first. ' '. $cust_main->get('last') );
226
227   $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
228
229   $return{payby} = $cust_main->payby;
230
231   if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
232     #warn $return{card_type} = cardtype($cust_main->payinfo);
233     $return{payinfo} = $cust_main->payinfo;
234
235     @return{'month', 'year'} = $cust_main->paydate_monthyear;
236
237   }
238
239   #list all counties/states/countries
240   $return{'cust_main_county'} = 
241       [ map { $_->hashref } qsearch('cust_main_county', {}) ];
242
243   #shortcut for one-country folks
244   my $conf = new FS::Conf;
245   my %states = map { $_->state => 1 }
246                  qsearch('cust_main_county', {
247                    'country' => $conf->config('defaultcountry') || 'US'
248                  } );
249   $return{'states'} = [ sort { $a cmp $b } keys %states ];
250
251   $return{card_types} = {
252     'VISA' => 'VISA card',
253     'MasterCard' => 'MasterCard',
254     'Discover' => 'Discover card',
255     'American Express' => 'American Express card',
256   };
257
258   my $_date = time;
259   $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
260
261   return { 'error' => '',
262            %return,
263          };
264
265 };
266
267 #some false laziness with httemplate/process/payment.cgi - look there for
268 #ACH and CVV support stuff
269 sub process_payment {
270
271   my $p = shift;
272
273   my $session = $cache->get($p->{'session_id'})
274     or return { 'error' => "Can't resume session" }; #better error message
275
276   my %return;
277
278   my $custnum = $session->{'custnum'};
279
280   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
281     or return { 'error' => "unknown custnum $custnum" };
282
283   $p->{'payname'} =~ /^([\w \,\.\-\']+)$/
284     or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} };
285   my $payname = $1;
286
287   $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
288     or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} };
289   my $paybatch = $1;
290
291   my $payinfo;
292   my $paycvv = '';
293   #if ( $payby eq 'CHEK' ) {
294   #
295   #  $p->{'payinfo1'} =~ /^(\d+)$/
296   #    or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
297   #  my $payinfo1 = $1;
298   #   $p->{'payinfo2'} =~ /^(\d+)$/
299   #    or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
300   #  my $payinfo2 = $1;
301   #  $payinfo = $payinfo1. '@'. $payinfo2;
302   # 
303   #} elsif ( $payby eq 'CARD' ) {
304    
305     $payinfo = $p->{'payinfo'};
306     $payinfo =~ s/\D//g;
307     $payinfo =~ /^(\d{13,16})$/
308       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
309     $payinfo = $1;
310     validate($payinfo)
311       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
312     return { 'error' => gettext('unknown_card_type') }
313       if cardtype($payinfo) eq "Unknown";
314
315     if ( defined $cust_main->dbdef_table->column('paycvv') ) {
316       if ( length($p->{'paycvv'} ) ) {
317         if ( cardtype($payinfo) eq 'American Express card' ) {
318           $p->{'paycvv'} =~ /^(\d{4})$/
319             or return { 'error' => "CVV2 (CID) for American Express cards is four digits." };
320           $paycvv = $1;
321         } else {
322           $p->{'paycvv'} =~ /^(\d{3})$/
323             or return { 'error' => "CVV2 (CVC2/CID) is three digits." };
324           $paycvv = $1;
325         }
326       }
327     }
328   
329   #} else {
330   #  die "unknown payby $payby";
331   #}
332
333   my $error = $cust_main->realtime_bop( 'CC', $p->{'amount'},
334     'quiet'    => 1,
335     'payinfo'  => $payinfo,
336     'paydate'  => $p->{'year'}. '-'. $p->{'month'}. '-01',
337     'payname'  => $payname,
338     'paybatch' => $paybatch,
339     'paycvv'   => $paycvv,
340     map { $_ => $p->{$_} } qw( address1 address2 city state zip )
341   );
342   return { 'error' => $error } if $error;
343
344   $cust_main->apply_payments;
345
346   if ( $p->{'save'} ) {
347     my $new = new FS::cust_main { $cust_main->hash };
348     $new->set( $_ => $p->{$_} )
349       foreach qw( payname address1 address2 city state zip payinfo );
350     $new->set( 'paydate' => $p->{'year'}. '-'. $p->{'month'}. '-01' );
351     $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
352     my $error = $new->replace($cust_main);
353     return { 'error' => $error } if $error;
354     $cust_main = $new;
355   }
356
357   return { 'error' => '' };
358
359 }
360
361 sub invoice {
362   my $p = shift;
363   my $session = $cache->get($p->{'session_id'})
364     or return { 'error' => "Can't resume session" }; #better error message
365
366   my $custnum = $session->{'custnum'};
367
368   my $invnum = $p->{'invnum'};
369
370   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
371                                           'custnum' => $custnum } )
372     or return { 'error' => "Can't find invnum" };
373
374   #my %return;
375
376   return { 'error'        => '',
377            'invnum'       => $invnum,
378            'invoice_text' => join('', $cust_bill->print_text ),
379          };
380
381 }
382
383 sub list_invoices {
384   my $p = shift;
385   my $session = $cache->get($p->{'session_id'})
386     or return { 'error' => "Can't resume session" }; #better error message
387
388   my $custnum = $session->{'custnum'};
389
390   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
391     or return { 'error' => "unknown custnum $custnum" };
392
393   my @cust_bill = $cust_main->cust_bill;
394
395   return  { 'error'       => '',
396             'invoices'    =>  [ map { { 'invnum' => $_->invnum,
397                                         '_date'  => $_->_date,
398                                       }
399                                     } @cust_bill
400                               ]
401           };
402 }
403
404 sub cancel {
405   my $p = shift;
406   my $session = $cache->get($p->{'session_id'})
407     or return { 'error' => "Can't resume session" }; #better error message
408
409   my $custnum = $session->{'custnum'};
410
411   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
412     or return { 'error' => "unknown custnum $custnum" };
413
414   my @errors = $cust_main->cancel( 'quiet'=>1 );
415
416   my $error = scalar(@errors) ? join(' / ', @errors) : '';
417
418   return { 'error' => $error };
419
420 }
421
422 sub list_pkgs {
423   my $p = shift;
424   my $session = $cache->get($p->{'session_id'})
425     or return { 'error' => "Can't resume session" }; #better error message
426
427   my $custnum = $session->{'custnum'};
428
429   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
430     or return { 'error' => "unknown custnum $custnum" };
431
432   return { 'cust_pkg' => [ map { $_->hashref } $cust_main->ncancelled_pkgs ] };
433
434 }
435
436 sub order_pkg {
437   my $p = shift;
438
439   my($session, $custnum, $context);
440
441   if ( $p->{'session_id'} ) {
442     $context = 'customer';
443     $session = $cache->get($p->{'session_id'})
444       or return { 'error' => "Can't resume session" }; #better error message
445     $custnum = $session->{'custnum'};
446   } elsif ( $p->{'agent_session_id'} ) {
447     $context = 'agent';
448     my $agent_cache = new Cache::SharedMemoryCache( {
449       'namespace' => 'FS::ClientAPI::Agent',
450     } );
451     $session = $agent_cache->get($p->{'agent_session_id'})
452       or return { 'error' => "Can't resume session" }; #better error message
453     $custnum = $p->{'custnum'};
454   } else {
455     return { 'error' => "Can't resume session" }; #better error message
456   }
457
458   my $search = { 'custnum' => $custnum };
459   $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
460
461   my $cust_main = qsearchs('cust_main', $search )
462     or return { 'error' => "unknown custnum $custnum" };
463
464   #false laziness w/ClientAPI/Signup.pm
465
466   my $cust_pkg = new FS::cust_pkg ( {
467     'custnum' => $custnum,
468     'pkgpart' => $p->{'pkgpart'},
469   } );
470   my $error = $cust_pkg->check;
471   return { 'error' => $error } if $error;
472
473   my @svc = ();
474   unless ( $p->{'svcpart'} eq 'none' ) {
475
476     my $svcdb;
477     my $svcpart = '';
478     if ( $p->{'svcpart'} =~ /^(\d+)$/ ) {
479       $svcpart = $1;
480       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
481       return { 'error' => "Unknown svcpart $svcpart" } unless $part_svc;
482       $svcdb = $part_svc->svcdb;
483     } else {
484       $svcdb = 'svc_acct';
485     }
486     $svcpart ||= $cust_pkg->part_pkg->svcpart($svcdb);
487
488     my %fields = (
489       'svc_acct'     => [ qw( username _password sec_phrase popnum ) ],
490       'svc_domain'   => [ qw( domain ) ],
491       'svc_external' => [ qw( id title ) ],
492     );
493   
494     my $svc_x = "FS::$svcdb"->new( {
495       'svcpart'   => $svcpart,
496       map { $_ => $p->{$_} } @{$fields{$svcdb}}
497     } );
498     
499     if ( $svcdb eq 'svc_acct' ) {
500       my @acct_snarf;
501       my $snarfnum = 1;
502       while ( length($p->{"snarf_machine$snarfnum"}) ) {
503         my $acct_snarf = new FS::acct_snarf ( {
504           'machine'   => $p->{"snarf_machine$snarfnum"},
505           'protocol'  => $p->{"snarf_protocol$snarfnum"},
506           'username'  => $p->{"snarf_username$snarfnum"},
507           '_password' => $p->{"snarf_password$snarfnum"},
508         } );
509         $snarfnum++;
510         push @acct_snarf, $acct_snarf;
511       }
512       $svc_x->child_objects( \@acct_snarf );
513     }
514     
515     my $y = $svc_x->setdefault; # arguably should be in new method
516     return { 'error' => $y } if $y && !ref($y);
517   
518     $error = $svc_x->check;
519     return { 'error' => $error } if $error;
520
521     push @svc, $svc_x;
522
523   }
524
525   use Tie::RefHash;
526   tie my %hash, 'Tie::RefHash';
527   %hash = ( $cust_pkg => \@svc );
528   #msgcat
529   $error = $cust_main->order_pkgs( \%hash, '', 'noexport' => 1 );
530   return { 'error' => $error } if $error;
531
532   my $conf = new FS::Conf;
533   if ( $conf->exists('signup_server-realtime') ) {
534
535     my $old_balance = $cust_main->balance;
536
537     my $bill_error = $cust_main->bill;
538     $cust_main->apply_payments;
539     $cust_main->apply_credits;
540     $bill_error = $cust_main->collect;
541
542     if ( $cust_main->balance > $old_balance
543          && $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/ ) {
544       #this makes sense.  credit is "un-doing" the invoice
545       $cust_main->credit( sprintf("%.2f", $cust_main->balance - $old_balance ),
546                           'self-service decline' );
547       $cust_main->apply_credits( 'order' => 'newest' );
548
549       $cust_pkg->cancel('quiet'=>1);
550       return { 'error' => '_decline' };
551     } else {
552       $cust_pkg->reexport;
553     }
554
555   } else {
556     $cust_pkg->reexport;
557   }
558
559   return { error => '', pkgnum => $cust_pkg->pkgnum };
560
561 }
562
563 sub cancel_pkg {
564   my $p = shift;
565   my $session = $cache->get($p->{'session_id'})
566     or return { 'error' => "Can't resume session" }; #better error message
567
568   my $custnum = $session->{'custnum'};
569
570   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
571     or return { 'error' => "unknown custnum $custnum" };
572
573   my $pkgnum = $p->{'pkgnum'};
574
575   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
576                                         'pkgnum'  => $pkgnum,   } )
577     or return { 'error' => "unknown pkgnum $pkgnum" };
578
579   my $error = $cust_pkg->cancel( 'quiet'=>1 );
580   return { 'error' => $error };
581
582 }
583
584 1;
585