RT# 39340 - created access to payment only via ip address, fixes security by creating...
[freeside.git] / FS / FS / ClientAPI / PaymentOnly.pm
1 package FS::ClientAPI::PaymentOnly;
2
3 use 5.008; #require 5.8+ for Time::Local 1.05+
4 use strict;
5 use vars qw( $cache $DEBUG $me );
6 use subs qw( _cache _provision );
7 use FS::ClientAPI_SessionCache;
8
9 use IO::Scalar;
10 use Data::Dumper;
11 use Digest::MD5 qw(md5_hex);
12 use Digest::SHA qw(sha512_hex);
13 use Date::Format;
14 use Time::Duration;
15 use Time::Local qw(timelocal_nocheck);
16 use Business::CreditCard 0.35;
17 use HTML::Entities;
18 use Text::CSV_XS;
19 use Spreadsheet::WriteExcel;
20 use OLE::Storage_Lite;
21 use FS::UI::Web::small_custview qw(small_custview); #less doh
22 use FS::UI::Web;
23 use FS::UI::bytecount qw( display_bytecount );
24 use FS::Conf;
25 #use FS::UID qw(dbh);
26 use FS::Record qw(qsearch qsearchs dbh);
27 use FS::Msgcat qw(gettext);
28 use FS::Misc qw(card_types money_pretty);
29 use FS::Misc::DateTime qw(parse_datetime);
30 use FS::TicketSystem;
31 use FS::ClientAPI_SessionCache;
32 use FS::cust_svc;
33 use FS::svc_acct;
34 use FS::svc_forward;
35 use FS::svc_domain;
36 use FS::svc_phone;
37 use FS::svc_external;
38 use FS::svc_dsl;
39 use FS::dsl_device;
40 use FS::part_svc;
41 use FS::cust_main;
42 use FS::cust_bill;
43 use FS::legacy_cust_bill;
44 use FS::cust_main_county;
45 use FS::part_pkg;
46 use FS::cust_pkg;
47 use FS::payby;
48 use FS::acct_rt_transaction;
49 use FS::msg_template;
50 use FS::contact;
51 use FS::cust_contact;
52 use FS::cust_location;
53 use FS::cust_payby;
54
55 $DEBUG = 0;
56 $me = '[FS::ClientAPI::PaymentOnly]';
57
58 sub _cache {
59   $cache ||= new FS::ClientAPI_SessionCache( {
60                'namespace' => 'FS::ClientAPI::PaymentOnly',
61              } );
62 }
63
64 sub payment_only_skin_info {
65   my $p = shift;
66
67   my($context, $session, $custnum) = _custoragent_session_custnum($p);
68   #return { 'error' => $session } if $context eq 'error';
69
70   my $agentnum = '';
71   if ( $context eq 'customer' && $custnum ) {
72
73     my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?')
74       or die dbh->errstr;
75
76     $sth->execute($custnum) or die $sth->errstr;
77
78     $agentnum = $sth->fetchrow_arrayref->[0]
79       or die "no agentnum for custnum $custnum";
80
81   #} elsif ( $context eq 'agent' ) {
82   } elsif ( defined($p->{'agentnum'}) and $p->{'agentnum'} =~ /^(\d+)$/ ) {
83     $agentnum = $1;
84   }
85   $p->{'agentnum'} = $agentnum;
86
87   my $conf = new FS::Conf;
88
89   #false laziness w/Signup.pm
90
91   my $skin_info_cache_agent = _cache->get("skin_info_cache_agent$agentnum");
92
93   if ( $skin_info_cache_agent ) {
94
95     warn "$me loading cached skin info for agentnum $agentnum\n"
96       if $DEBUG > 1;
97
98   } else {
99
100     warn "$me populating skin info cache for agentnum $agentnum\n"
101       if $DEBUG > 1;
102
103     $skin_info_cache_agent = {
104       'agentnum' => $agentnum,
105       ( map { $_ => scalar( $conf->config($_, $agentnum) ) }
106         qw( company_name date_format ) ),
107       ( map { $_ => scalar( $conf->config("selfservice-$_", $agentnum ) ) }
108         qw( body_bgcolor box_bgcolor stripe1_bgcolor stripe2_bgcolor
109             text_color link_color vlink_color hlink_color alink_color
110             font title_color title_align title_size menu_bgcolor menu_fontsize
111           )
112       ),
113       'menu_disable' => [ $conf->config('selfservice-menu_disable',$agentnum) ],
114       ( map { $_ => $conf->exists("selfservice-$_", $agentnum ) }
115         qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo enable_payment_without_balance )
116       ),
117       ( map { $_ => scalar($conf->config_binary("selfservice-$_", $agentnum)) }
118         qw( title_left_image title_right_image
119             menu_top_image menu_body_image menu_bottom_image
120           )
121       ),
122       'logo' => scalar($conf->config_binary('logo.png', $agentnum )),
123       ( map { $_ => join("\n", $conf->config("selfservice-$_", $agentnum ) ) }
124         qw( head body_header body_footer company_address ) ),
125       'money_char' => $conf->config("money_char") || '$',
126       'menu' => 'payment_only_payment.php Make Payment
127
128                  payment_only_logout.php Logout
129                 ',
130     };
131
132     _cache->set("skin_info_cache_agent$agentnum", $skin_info_cache_agent);
133
134   }
135
136   #{ %$skin_info_cache_agent };
137   $skin_info_cache_agent;
138
139 }
140
141 sub ip_login {
142   my $p = shift;
143
144   my $conf = new FS::Conf;
145
146   my $svc_x = '';
147   my $session = {};
148   my $cust_main;
149
150   return { error => 'MAC address empty '.$p->{'mac'} }
151     unless $p->{'mac'};
152
153       my $mac_address = $p->{'mac'};
154       $mac_address =~ s/[\:\,\-\. ]//g;
155       $mac_address =~ tr/[a-z]/[A-Z/;
156
157       my $svc_broadband = qsearchs( 'svc_broadband', { 'mac_addr' => $mac_address } );
158       return { error => 'MAC address not found $mac_address '.$p->{'mac'} }
159         unless $svc_broadband;
160       $svc_x = $svc_broadband;
161
162   if ( $svc_x ) {
163
164     $session->{'svcnum'} = $svc_x->svcnum;
165
166     my $cust_svc = $svc_x->cust_svc;
167     my $cust_pkg = $cust_svc->cust_pkg;
168     if ( $cust_pkg ) {
169       $cust_main = $cust_pkg->cust_main;
170       $session->{'custnum'} = $cust_main->custnum;
171       if ( $conf->exists('pkg-balances') ) {
172         my @cust_pkg = grep { $_->part_pkg->freq !~ /^(0|$)/ }
173                             $cust_main->ncancelled_pkgs;
174         $session->{'pkgnum'} = $cust_pkg->pkgnum
175           if scalar(@cust_pkg) > 1;
176       }
177     }
178
179     #my $pkg_svc = $svc_acct->cust_svc->pkg_svc;
180     #return { error => 'Only primary user may log in.' } 
181     #  if $conf->exists('selfservice_server-primary_only')
182     #    && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' );
183     my $part_pkg = $cust_pkg->part_pkg;
184     return { error => 'Only primary user may log in.' }
185       if $conf->exists('selfservice_server-primary_only')
186          && $cust_svc->svcpart != $part_pkg->svcpart([qw( svc_acct svc_phone )]);
187
188   }
189   else {
190     return { error => "No Service Found with Mac Address ".$p->{'mac'} };
191   }
192
193   ## get account information
194   my ($cust_payby_card) = $cust_main->cust_payby('CARD', 'DCRD');
195   if ($cust_payby_card) {
196     $session->{'CARD'} = $cust_payby_card->custpaybynum;
197   }
198   my ($cust_payby_check) = $cust_main->cust_payby('CHEK', 'DCHK');
199   if ($cust_payby_check) {
200     $session->{'CHEK'} = $cust_payby_check->custpaybynum;
201   }
202
203   my $session_id;
204   do {
205     $session_id = sha512_hex(time(). {}. rand(). $$)
206   } until ( ! defined _cache->get($session_id) ); #just in case
207
208   my $timeout = $conf->config('selfservice-session_timeout') || '1 hour';
209   _cache->set( $session_id, $session, $timeout );
210
211   return { 'error'      => '',
212            'session_id' => $session_id,
213            %$session,
214          };
215 }
216
217 sub ip_logout {
218   my $p = shift;
219   my $skin_info = skin_info($p);
220   if ( $p->{'session_id'} ) {
221     _cache->remove($p->{'session_id'});
222     return { %$skin_info, 'error' => '' };
223   } else {
224     return { %$skin_info, 'error' => "Can't resume session" }; #better error message
225   }
226 }
227
228 sub get_mac_address {
229   my $p = shift;
230
231 ## access radius exports acct tables to get mac
232   my @part_export = ();
233   @part_export = (
234     qsearch( 'part_export', { 'exporttype' => 'sqlradius' } ),
235     qsearch( 'part_export', { 'exporttype' => 'sqlradius_withdomain' } ),
236     qsearch( 'part_export', { 'exporttype' => 'broadband_sqlradius' } ),
237   );
238
239   my @sessions;
240   foreach my $part_export (@part_export) {
241     push @sessions, ( @{ $part_export->usage_sessions( {
242       'ip' => $p->{'ip'},
243       'session_status' => 'open',
244     } ) } );
245   }
246
247   return { 'mac_address' => $sessions[0]->{'callingstationid'}, };
248 }
249
250 sub payment_only_payment_info {
251   my $p = shift;
252   my $session = _cache->get($p->{'session_id'})
253     or return { 'error' => "Can't resume session today" }; #better error message
254
255   my $custnum = $session->{'custnum'};
256   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
257     or return { 'error' => "unknown custnum $custnum" };
258
259   my $payment_info = {
260     'balance' => $cust_main->balance,
261   };
262
263   #doubleclick protection
264   my $_date = time;
265   $payment_info->{'payunique'} = "webui-PaymentOnly-$_date-$$-". rand() * 2**32; #new
266   $payment_info->{'paybatch'} = $payment_info->{'payunique'};  #back compat
267
268   if ($session->{'CARD'}) {
269     my $card_payby = qsearchs('cust_payby', { 'custpaybynum' => $session->{'CARD'} });
270     if ($card_payby) {
271        $payment_info->{'CARD'} = $session->{'CARD'};
272        $payment_info->{'card_mask'} = $card_payby->paymask;
273        $payment_info->{'card_type'} = $card_payby->paycardtype;
274     }
275   }
276
277   if ($session->{'CHEK'}) {
278     my $check_payby = qsearchs('cust_payby', { 'custpaybynum' => $session->{'CHEK'} });
279     if ($check_payby) {
280        my ($payaccount, $payaba) = split /\@/, $check_payby->paymask;
281        $payment_info->{'CHEK'} = $session->{'CHEK'};
282        $payment_info->{'check_mask'} = $payaccount;
283        $payment_info->{'check_type'} = $check_payby->paytype;
284     }
285   }
286
287   return $payment_info;
288
289 }
290
291 sub payment_only_process_payment {
292   my $p = shift;
293
294   my $payment_info = _validate_payment($p);
295   return $payment_info if $payment_info->{'error'};
296
297   FS::ClientAPI::MyAccount::do_process_payment($payment_info);
298
299   #return;
300 }
301
302 sub _validate_payment {
303   my $p = shift;
304
305   my $session = _cache->get($p->{'session_id'})
306     or return { 'error' => "Can't resume session" }; #better error message
307
308   my $custnum = $session->{'custnum'};
309
310   my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
311     or return { 'error' => "unknown custnum $custnum" };
312
313   $p->{'amount'} =~ /^\s*(\d+(\.\d{2})?)\s*$/
314     or return { 'error' => gettext('illegal_amount') };
315   my $amount = $1;
316   return { error => 'Amount must be greater than 0' } unless $amount > 0;
317
318   #false laziness w/tr-amount_fee.html, but we don't want selfservice users
319   #changing the hidden form values
320   my $conf = new FS::Conf;
321   my $fee_display = $conf->config('selfservice_process-display') || 'add';
322   my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
323   my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
324   if ( $fee_display eq 'add'
325          and $fee_pkgpart
326          and ! $fee_skip_first || scalar($cust_main->cust_pay)
327      )
328   {
329     my $fee_pkg = qsearchs('part_pkg', { pkgpart=>$fee_pkgpart } );
330     $amount = sprintf('%.2f', $amount + $fee_pkg->option('setup_fee') );
331   }
332
333   #$p->{'payby'} ||= 'CARD';
334   $p->{'payby'} =~ /^([A-Z]{4})$/
335     or return { 'error' => "illegal_payby " . $p->{'payby'} };
336   my $payby = $1;
337
338   ## get info from custpaybynum.
339   my $custpayby = qsearchs('cust_payby', { custpaybynum => $session->{$p->{'payby'}} } )
340     or return { 'error' => 'No payment information found' };
341
342   $p->{'discount_term'} =~ /^\s*(\d*)\s*$/
343     or return { 'error' => gettext('illegal_discount_term'). ': '. $p->{'discount_term'} };
344   my $discount_term = $1;
345
346   $p->{'payunique'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
347     or return { 'error' => gettext('illegal_text'). " payunique: ". $p->{'payunique'} };
348   my $payunique = $1;
349
350   $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/
351     or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} };
352   my $paybatch = $1;
353
354   $payunique = $paybatch if ! length($payunique) && length($paybatch);
355   my $payname = $custpayby->payname;
356
357   #false laziness w/process/payment.cgi
358   my $payinfo = $custpayby->payinfo;
359   my $onfile = 1;
360   my $paycvv = '';
361   my $replace_cust_payby;
362
363   if ( $payby eq 'CHEK' || $payby eq 'DCHK' ) {
364
365     my ($payinfo1, $payinfo2) = split /\@/, $payinfo;
366     $payinfo1 =~ /^([\dx]+)$/
367       or return { 'error' => "illegal account number " };
368      $payinfo2 =~ /^([\dx]+)$/
369       or return { 'error' => "illegal ABA/routing number " }; 
370
371   } elsif ( $payby eq 'CARD' || $payby eq 'DCRD' ) {
372
373     $payinfo =~ s/\D//g;
374     $payinfo =~ /^(\d{13,19}|\d{8,9})$/
375       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
376     $payinfo = $1;
377
378     validate($payinfo)
379       or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo
380     return { 'error' => gettext('unknown_card_type') }
381       if !$cust_main->tokenized($payinfo) && cardtype($payinfo) eq "Unknown";
382
383     if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) {
384       if ( cardtype($payinfo) eq 'American Express card' ) {
385         $p->{'paycvv'} =~ /^\s*(\d{4})\s*$/
386           or return { 'error' => "CVV2 (CID) for American Express cards is four digits." };
387         $paycvv = $1;
388       } else {
389         $p->{'paycvv'} =~ /^\s*(\d{3})\s*$/
390           or return { 'error' => "CVV2 (CVC2/CID) is three digits." };
391         $paycvv = $1;
392       }
393     } elsif ( $conf->exists('selfservice-onfile_require_cvv') ) {
394       return { 'error' => 'CVV2 is required' };
395     } elsif ( !$onfile && $conf->exists('selfservice-require_cvv') ) {
396       return { 'error' => 'CVV2 is required' };
397     }
398   
399   } else {
400     die "unknown payby $payby";
401   }
402
403   $p->{$_} = $cust_main->bill_location->get($_) 
404     for qw(address1 address2 city state zip);
405
406   my %payby2fields = (
407     'CARD' => [ qw( paystart_month paystart_year payissue payip
408                     address1 address2 city state zip country    ) ],
409     'CHEK' => [ qw( ss paytype paystate stateid stateid_state payip ) ],
410   );
411
412   my $card_type = '';
413   $card_type = cardtype($payinfo) if $payby eq 'CARD';
414
415   my ($year, $month, $day) = split /-/, $custpayby->{Hash}->{paydate};
416
417   my $return = { 
418     'cust_main'      => $cust_main, #XXX or just custnum??
419     'amount'         => sprintf('%.2f', $amount),
420     'payby'          => $payby,
421     'payinfo'        => $payinfo,
422     'paymask'        => $custpayby->paymask,
423     'card_type'      => $card_type,
424     'paydate'        => $custpayby->paydate,
425     'paydate_pretty' => $month. ' / '. $year,
426     'month'          => $month,
427     'year'           => $year,
428     'payname'        => $custpayby->{HASH}->{payname},
429     'payunique'      => $payunique,
430     'paybatch'       => $paybatch,
431     'paycvv'         => $paycvv,
432     'payname'        => $payname,
433     'discount_term'  => $discount_term,
434     'pkgnum'         => $session->{'pkgnum'},
435     map { $_ => $p->{$_} } ( @{ $payby2fields{$payby} } )
436   };
437
438   return $return;
439
440 }
441
442 sub _custoragent_session_custnum {
443   my $p = shift;
444
445   my($context, $session, $custnum);
446   if ( $p->{'session_id'} ) {
447
448     $context = 'customer';
449     $session = _cache->get($p->{'session_id'})
450       or return ( 'error' => "Can't resume session" ); #better error message
451     $custnum = $session->{'custnum'};
452
453   } elsif ( $p->{'agent_session_id'} ) {
454
455     $context = 'agent';
456     my $agent_cache = new FS::ClientAPI_SessionCache( {
457       'namespace' => 'FS::ClientAPI::Agent',
458     } );
459     $session = $agent_cache->get($p->{'agent_session_id'})
460       or return ( 'error' => "Can't resume session" ); #better error message
461     $custnum = $p->{'custnum'};
462
463   } else {
464     $context = 'error';
465     return ( 'error' => "Can't resume session" ); #better error message
466   }
467
468   ($context, $session, $custnum);
469
470 }
471
472 1;