add methods for masking credit cards, add payment info modification to 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::cust_main;
16 use FS::cust_bill;
17 use FS::cust_main_county;
18 use FS::cust_pkg;
19
20 use FS::ClientAPI; #hmm
21 FS::ClientAPI->register_handlers(
22   'MyAccount/login'            => \&login,
23   'MyAccount/customer_info'    => \&customer_info,
24   'MyAccount/edit_info'        => \&edit_info,
25   'MyAccount/invoice'          => \&invoice,
26   'MyAccount/cancel'           => \&cancel,
27   'MyAccount/payment_info'     => \&payment_info,
28   'MyAccount/process_payment'  => \&process_payment,
29   'MyAccount/list_pkgs'        => \&list_pkgs,
30   'MyAccount/order_pkg'        => \&order_pkg,
31   'MyAccount/cancel_pkg'       => \&cancel_pkg,
32   'MyAccount/charge'           => \&charge,
33 );
34
35 use vars qw( @cust_main_editable_fields );
36 @cust_main_editable_fields = qw(
37   first last company address1 address2 city
38     county state zip country daytime night fax
39   ship_first ship_last ship_company ship_address1 ship_address2 ship_city
40     ship_state ship_zip ship_country ship_daytime ship_night ship_fax
41   payby payinfo payname
42 );
43
44 #store in db?
45 my $cache = new Cache::SharedMemoryCache( {
46    'namespace' => 'FS::ClientAPI::MyAccount',
47 } );
48
49 #false laziness w/FS::ClientAPI::passwd::passwd
50 sub login {
51   my $p = shift;
52
53   my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
54     or return { error => 'Domain '. $p->{'domain'}. ' not found' };
55
56   my $svc_acct = qsearchs( 'svc_acct', { 'username'  => $p->{'username'},
57                                          'domsvc'    => $svc_domain->svcnum, }
58                          );
59   return { error => 'User not found.' } unless $svc_acct;
60   return { error => 'Incorrect password.' }
61     unless $svc_acct->check_password($p->{'password'});
62
63   my $session = {
64     'svcnum' => $svc_acct->svcnum,
65   };
66
67   my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
68   if ( $cust_pkg ) {
69     my $cust_main = $cust_pkg->cust_main;
70     $session->{'custnum'} = $cust_main->custnum;
71   }
72
73   my $session_id;
74   do {
75     $session_id = md5_hex(md5_hex(time(). {}. rand(). $$))
76   } until ( ! defined $cache->get($session_id) ); #just in case
77
78   $cache->set( $session_id, $session, '1 hour' );
79
80   return { 'error'      => '',
81            'session_id' => $session_id,
82          };
83 }
84
85 sub customer_info {
86   my $p = shift;
87   my $session = $cache->get($p->{'session_id'})
88     or return { 'error' => "Can't resume session" }; #better error message
89
90   my %return;
91
92   my $custnum = $session->{'custnum'};
93
94   if ( $custnum ) { #customer record
95
96     my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
97       or return { 'error' => "unknown custnum $custnum" };
98
99     $return{balance} = $cust_main->balance;
100
101     my @open = map {
102                      {
103                        invnum => $_->invnum,
104                        date   => time2str("%b %o, %Y", $_->_date),
105                        owed   => $_->owed,
106                      };
107                    } $cust_main->open_cust_bill;
108     $return{open_invoices} = \@open;
109
110     my $conf = new FS::Conf;
111     $return{small_custview} =
112       small_custview( $cust_main, $conf->config('defaultcountry') );
113
114     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
115
116     for (@cust_main_editable_fields) {
117       $return{$_} = $cust_main->get($_);
118     }
119
120     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
121       $return{payinfo} = $cust_main->masked_payinfo;
122       @return{'month', 'year'} = $cust_main->paydate_monthyear;
123     }
124
125     $return{'invoicing_list'} =
126       join(', ', grep { $_ ne 'POST' } $cust_main->invoicing_list );
127     $return{'postal_invoicing'} =
128       0 < ( grep { $_ eq 'POST' } $cust_main->invoicing_list );
129
130   } else { #no customer record
131
132     my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $session->{'svcnum'} } )
133       or die "unknown svcnum";
134     $return{name} = $svc_acct->email;
135
136   }
137
138   return { 'error'          => '',
139            'custnum'        => $custnum,
140            %return,
141          };
142
143 }
144
145 sub edit_info {
146   my $p = shift;
147   my $session = $cache->get($p->{'session_id'})
148     or return { 'error' => "Can't resume session" }; #better error message
149
150   my $custnum = $session->{'custnum'}
151     or return { 'error' => "no customer record" };
152
153   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
154     or return { 'error' => "unknown custnum $custnum" };
155
156   my $new = new FS::cust_main { $cust_main->hash };
157   $new->set( $_ => $p->{$_} )
158     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
159
160   if ( $p->{'payby'} =~ /^(CARD|DCRD)$/ ) {
161     $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01');
162     if ( $new->payinfo eq $cust_main->payinfo_masked ) {
163       $new->payinfo($cust_main->payinfo);
164     } else {
165       $new->paycvv($p->{'paycvv'});
166     }
167   }
168
169   my @invoicing_list;
170   if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) {
171     #false laziness with httemplate/edit/process/cust_main.cgi
172     @invoicing_list = split( /\s*\,\s*/, $p->{'invoicing_list'} );
173     push @invoicing_list, 'POST' if $p->{'postal_invoicing'};
174   } else {
175     @invoicing_list = $cust_main->invoicing_list;
176   }
177
178   my $error = $new->replace($cust_main, \@invoicing_list);
179   return { 'error' => $error } if $error;
180   #$cust_main = $new;
181   
182   return { 'error' => '' };
183 }
184
185 sub payment_info {
186   my $p = shift;
187   my $session = $cache->get($p->{'session_id'})
188     or return { 'error' => "Can't resume session" }; #better error message
189
190   my %return;
191
192   my $custnum = $session->{'custnum'};
193
194   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
195     or return { 'error' => "unknown custnum $custnum" };
196
197   $return{balance} = $cust_main->balance;
198
199   $return{payname} = $cust_main->payname
200                      || ( $cust_main->first. ' '. $cust_main->get('last') );
201
202   $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
203
204   $return{payby} = $cust_main->payby;
205
206   if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
207     #warn $return{card_type} = cardtype($cust_main->payinfo);
208     $return{payinfo} = $cust_main->payinfo;
209
210     @return{'month', 'year'} = $cust_main->paydate_monthyear;
211
212   }
213
214   #list all counties/states/countries
215   $return{'cust_main_county'} = 
216       [ map { $_->hashref } qsearch('cust_main_county', {}) ],
217
218   #shortcut for one-country folks
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   $return{'states'} = [ sort { $a cmp $b } keys %states ];
225
226   $return{card_types} = {
227     'VISA' => 'VISA card',
228     'MasterCard' => 'MasterCard',
229     'Discover' => 'Discover card',
230     'American Express' => 'American Express card',
231   };
232
233   my $_date = time;
234   $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
235
236   return { 'error' => '',
237            %return,
238          };
239
240 };
241
242 #some false laziness with httemplate/process/payment.cgi - look there for
243 #ACH and CVV support stuff
244 sub process_payment {
245
246   my $p = shift;
247
248   my $session = $cache->get($p->{'session_id'})
249     or return { 'error' => "Can't resume session" }; #better error message
250
251   my %return;
252
253   my $custnum = $session->{'custnum'};
254
255   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
256     or return { 'error' => "unknown custnum $custnum" };
257
258   $p->{'payname'} =~ /^([\w \,\.\-\']+)$/
259     or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} };
260   my $payname = $1;
261
262   $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
263     or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} };
264   my $paybatch = $1;
265
266   my $payinfo;
267   my $paycvv = '';
268   #if ( $payby eq 'CHEK' ) {
269   #
270   #  $p->{'payinfo1'} =~ /^(\d+)$/
271   #    or return { 'error' => "illegal account number ". $p->{'payinfo1'} };
272   #  my $payinfo1 = $1;
273   #   $p->{'payinfo2'} =~ /^(\d+)$/
274   #    or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} };
275   #  my $payinfo2 = $1;
276   #  $payinfo = $payinfo1. '@'. $payinfo2;
277   # 
278   #} elsif ( $payby eq 'CARD' ) {
279    
280     $payinfo = $p->{'payinfo'};
281     $payinfo =~ s/\D//g;
282     $payinfo =~ /^(\d{13,16})$/
283       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
284     $payinfo = $1;
285     validate($payinfo)
286       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
287     return { 'error' => gettext('unknown_card_type') }
288       if cardtype($payinfo) eq "Unknown";
289
290     if ( defined $cust_main->dbdef_table->column('paycvv') ) {
291       if ( length($p->{'paycvv'} ) ) {
292         if ( cardtype($payinfo) eq 'American Express card' ) {
293           $p->{'paycvv'} =~ /^(\d{4})$/
294             or return { 'error' => "CVV2 (CID) for American Express cards is four digits." };
295           $paycvv = $1;
296         } else {
297           $p->{'paycvv'} =~ /^(\d{3})$/
298             or return { 'error' => "CVV2 (CVC2/CID) is three digits." };
299           $paycvv = $1;
300         }
301       }
302     }
303   
304   #} else {
305   #  die "unknown payby $payby";
306   #}
307
308   my $error = $cust_main->realtime_bop( 'CC', $p->{'amount'},
309     'quiet'    => 1,
310     'payinfo'  => $payinfo,
311     'paydate'  => $p->{'year'}. '-'. $p->{'month'}. '-01',
312     'payname'  => $payname,
313     'paybatch' => $paybatch,
314     'paycvv'   => $paycvv,
315     map { $_ => $p->{$_} } qw( address1 address2 city state zip )
316   );
317   return { 'error' => $error } if $error;
318
319   $cust_main->apply_payments;
320
321   if ( $p->{'save'} ) {
322     my $new = new FS::cust_main { $cust_main->hash };
323     $new->set( $_ => $p->{$_} )
324       foreach qw( payname address1 address2 city state zip payinfo );
325     $new->set( 'paydate' => $p->{'year'}. '-'. $p->{'month'}. '-01' );
326     $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' );
327     my $error = $new->replace($cust_main);
328     return { 'error' => $error } if $error;
329     $cust_main = $new;
330   }
331
332   return { 'error' => '' };
333
334 }
335
336 sub invoice {
337   my $p = shift;
338   my $session = $cache->get($p->{'session_id'})
339     or return { 'error' => "Can't resume session" }; #better error message
340
341   my $custnum = $session->{'custnum'};
342
343   my $invnum = $p->{'invnum'};
344
345   my $cust_bill = qsearchs('cust_bill', { 'invnum'  => $invnum,
346                                           'custnum' => $custnum } )
347     or return { 'error' => "Can't find invnum" };
348
349   #my %return;
350
351   return { 'error'        => '',
352            'invnum'       => $invnum,
353            'invoice_text' => join('', $cust_bill->print_text ),
354          };
355
356 }
357
358 sub cancel {
359   my $p = shift;
360   my $session = $cache->get($p->{'session_id'})
361     or return { 'error' => "Can't resume session" }; #better error message
362
363   my $custnum = $session->{'custnum'};
364
365   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
366     or return { 'error' => "unknown custnum $custnum" };
367
368   my @errors = $cust_main->cancel( 'quiet'=>1 );
369
370   my $error = scalar(@errors) ? join(' / ', @errors) : '';
371
372   return { 'error' => $error };
373
374 }
375
376 sub list_pkgs {
377   my $p = shift;
378   my $session = $cache->get($p->{'session_id'})
379     or return { 'error' => "Can't resume session" }; #better error message
380
381   my $custnum = $session->{'custnum'};
382
383   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
384     or return { 'error' => "unknown custnum $custnum" };
385
386   return { 'cust_pkg' => [ map { $_->hashref } $cust_main->ncancelled_pkgs ] };
387
388 }
389
390 sub order_pkg {
391   my $p = shift;
392   my $session = $cache->get($p->{'session_id'})
393     or return { 'error' => "Can't resume session" }; #better error message
394
395   my $custnum = $session->{'custnum'};
396
397   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
398     or return { 'error' => "unknown custnum $custnum" };
399
400   #false laziness w/ClientAPI/Signup.pm
401
402   my $cust_pkg = new FS::cust_pkg ( {
403     'custnum' => $custnum,
404     'pkgpart' => $p->{'pkgpart'},
405   } );
406   my $error = $cust_pkg->check;
407   return { 'error' => $error } if $error;
408
409   my $svc_acct = new FS::svc_acct ( {
410     'svcpart'   => $p->{'svcpart'} || $cust_pkg->part_pkg->svcpart('svc_acct'),
411     map { $_ => $p->{$_} }
412       qw( username _password sec_phrase popnum ),
413   } );
414
415   my @acct_snarf;
416   my $snarfnum = 1;
417   while ( length($p->{"snarf_machine$snarfnum"}) ) {
418     my $acct_snarf = new FS::acct_snarf ( {
419       'machine'   => $p->{"snarf_machine$snarfnum"},
420       'protocol'  => $p->{"snarf_protocol$snarfnum"},
421       'username'  => $p->{"snarf_username$snarfnum"},
422       '_password' => $p->{"snarf_password$snarfnum"},
423     } );
424     $snarfnum++;
425     push @acct_snarf, $acct_snarf;
426   }
427   $svc_acct->child_objects( \@acct_snarf );
428
429   my $y = $svc_acct->setdefault; # arguably should be in new method
430   return { 'error' => $y } if $y && !ref($y);
431
432   $error = $svc_acct->check;
433   return { 'error' => $error } if $error;
434
435   use Tie::RefHash;
436   tie my %hash, 'Tie::RefHash';
437   %hash = ( $cust_pkg => [ $svc_acct ] );
438   #msgcat
439   $error = $cust_main->order_pkgs( \%hash, '', 'noexport' => 1 );
440   return { 'error' => $error } if $error;
441
442   my $conf = new FS::Conf;
443   if ( $conf->exists('signup_server-realtime') ) {
444
445     my $old_balance = $cust_main->balance;
446
447     my $bill_error = $cust_main->bill;
448     $cust_main->apply_payments;
449     $cust_main->apply_credits;
450     $bill_error = $cust_main->collect;
451
452     if ( $cust_main->balance > $old_balance ) {
453       $cust_pkg->cancel('quiet'=>1);
454       return { 'error' => '_decline' };
455     } else {
456       $cust_pkg->reexport;
457     }
458
459   } else {
460     $cust_pkg->reexport;
461   }
462
463   return { error => '' };
464
465 }
466
467 sub cancel_pkg {
468   my $p = shift;
469   my $session = $cache->get($p->{'session_id'})
470     or return { 'error' => "Can't resume session" }; #better error message
471
472   my $custnum = $session->{'custnum'};
473
474   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
475     or return { 'error' => "unknown custnum $custnum" };
476
477   my $pkgnum = $session->{'pkgnum'};
478
479   my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum,
480                                         'pkgnum'  => $pkgnum,   } )
481     or return { 'error' => "unknown pkgnum $pkgnum" };
482
483   my $error = $cust_main->cancel( 'quiet'=>1 );
484   return { 'error' => $error };
485
486 }
487
488 1;
489