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