Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / cust_main / Billing_ThirdParty.pm
1 package FS::cust_main::Billing_ThirdParty;
2
3 use strict;
4 use vars qw( $DEBUG $me );
5 use FS::Record qw( qsearch qsearchs dbh );
6 use FS::cust_pay;
7 use FS::cust_pay_pending;
8
9 $DEBUG = 0;
10 $me = '[FS::cust_main::Billing_ThirdParty]';
11 # arguably doesn't even belong under cust_main...
12
13 =head1 METHODS
14
15 =over 4
16
17 =item create_payment OPTIONS
18
19 Create a pending payment for a third-party gateway.  OPTIONS must include:
20 - method: a Business::OnlineThirdPartyPayment method argument.  Currently 
21   only supports PAYPAL.
22 - amount: a decimal amount.  Unlike in Billing_Realtime, there is NO default.
23 - session_id: the customer's self-service session ID.
24
25 and may optionally include:
26 - invnum: the invoice that this payment will apply to
27 - pkgnum: the package balance that this payment will apply to.
28 - description: the transaction description for the gateway.
29 - payip: the IP address the payment is initiated from
30
31 On failure, returns a simple string error message.  On success, returns 
32 a hashref of 'url' => the URL to redirect the user to to complete payment,
33 and optionally 'post_params' => a hashref of name/value pairs to be POSTed
34 to that URL.
35
36 =cut
37
38 my @methods = qw(PAYPAL CC);
39 my %method2payby = ( 'PAYPAL' => 'PPAL',
40                      'CC'     => 'MCRD', #?  but doesn't MCRD mean _offline_
41                                          #card, not third-party card?  but no
42                                          #one is doing non-paypal right now
43                    );
44
45 sub create_payment {
46   my $self = shift;
47   my %opt = @_;
48
49   # avoid duplicating this--we just need description and invnum
50   my $defaults;
51   $self->_bop_defaults($defaults);
52   
53   my $method = $opt{'method'} or return 'method required';
54   my $amount = $opt{'amount'} or return 'amount required';
55   return "unknown method '$method'" unless grep {$_ eq $method} @methods;
56   return "amount must be > 0" unless $amount > 0;
57   return "session_id required" unless length($opt{'session_id'});
58
59   my $gateway = $self->agent->payment_gateway(
60     method      => $method,
61     nofatal     => 1,
62     thirdparty  => 1,
63   );
64   return "no third-party gateway enabled for method $method" if !$gateway;
65
66   # create pending record
67   $self->select_for_update;
68   my @pending = qsearch('cust_pay_pending', {
69       'custnum' => $self->custnum,
70       'status'  => { op=>'!=', value=>'done' }
71   });
72
73   # if there are pending payments in the 'thirdparty' state,
74   # we can safely remove them
75   foreach (@pending) {
76     if ( $_->status eq 'thirdparty' ) {
77       my $error = $_->delete;
78       return "Error deleting unfinished payment #".
79         $_->paypendingnum . ": $error\n" if $error;
80     } else {
81       return "A payment is already being processed for this customer.";
82     }
83   }
84
85   my $cpp = FS::cust_pay_pending->new({
86       'custnum'         => $self->custnum,
87       'status'          => 'new',
88       'gatewaynum'      => $gateway->gatewaynum,
89       'paid'            => sprintf('%.2f',$opt{'amount'}),
90       'payby'           => $method2payby{ $opt{'method'} },
91       'pkgnum'          => $opt{'pkgnum'},
92       'invnum'          => $opt{'invnum'} || $defaults->{'invnum'},
93       'session_id'      => $opt{'session_id'},
94   });
95
96   my $error = $cpp->insert;
97   return $error if $error;
98
99   my $transaction = $gateway->processor;
100   # Not included in this content hash:
101   # payinfo, paydate, paycvv, any kind of recurring billing indicator,
102   # paystate, paytype (account type), stateid, ss, payname
103   #
104   # Also, unlike bop_realtime, we don't allow the magical %options hash
105   # to override the customer's information.  If they need to enter a 
106   # different address or something for the billing provider, they can do 
107   # that after the redirect.
108   my %content = (
109     'action'      => 'create',
110     'description' => $opt{'description'} || $defaults->{'description'},
111     'amount'      => $amount,
112     'customer_id' => $self->custnum,
113     'email'       => $self->invoicing_list_emailonly_scalar,
114     'customer_ip' => $opt{'payip'},
115     'first_name'  => $self->first,
116     'last_name'   => $self->last,
117     'address1'    => $self->address1,
118     'address2'    => $self->address2,
119     'city'        => $self->city,
120     'state'       => $self->state,
121     'zip'         => $self->zip,
122     'country'     => $self->country,
123     'phone'       => ($self->daytime || $self->night),
124   );
125
126   {
127     local $@;
128     eval { $transaction->create(%content) };
129     if ( $@ ) {
130       warn "ERROR: Executing third-party payment:\n$@\n";
131       return { error => $@ };
132     }
133   }
134
135   if ($transaction->is_success) {
136     $cpp->status('thirdparty');
137     # for whatever is most identifiable as the "transaction ID"
138     $cpp->payinfo($transaction->token);
139     # for anything else the transaction needs to remember
140     $cpp->statustext($transaction->statustext);
141     $error = $cpp->replace;
142     return $error if $error;
143
144     return {url => $transaction->redirect,
145             post_params => $transaction->post_params};
146
147   } else {
148     $cpp->status('done');
149     $cpp->statustext($transaction->error_message);
150     $error = $cpp->replace;
151     return $error if $error;
152
153     return $transaction->error_message;
154   }
155
156 }
157
158 =item execute_payment SESSION_ID, PARAMS
159
160 Complete the payment and get the status.  Triggered from the return_url
161 handler; PARAMS are all of the CGI parameters we received in the redirect.
162 On failure, returns an error message.  On success, returns a hashref of 
163 'paynum', 'paid', 'order_number', and 'auth'.
164
165 =cut
166
167 sub execute_payment {
168   my $self = shift;
169   my $session_id = shift;
170   my %params = @_;
171
172   my $cpp = qsearchs('cust_pay_pending', {
173       'session_id'  => uc($session_id),
174       'custnum'     => $self->custnum,
175       'status'      => 'thirdparty',
176   })
177     or return 'no payment in process for this session';
178
179   my $gateway = FS::payment_gateway->by_key( $cpp->gatewaynum );
180   my $transaction = $gateway->processor;
181   $transaction->token($cpp->payinfo);
182   $transaction->statustext($cpp->statustext);
183
184   {
185     local $@;
186     eval { $transaction->execute(%params) };
187     if ( $@ ) {
188       warn "ERROR: Executing third-party payment:\n$@\n";
189       return { error => $@ };
190     }
191   }
192
193   my $error;
194
195   if ( $transaction->is_success ) {
196
197     $error = $cpp->approve(
198                     'processor'     => $gateway->gateway_module,
199                     'order_number'  => $transaction->order_number,
200                     'auth'          => $transaction->authorization,
201                     'payinfo'       => '',
202                     'apply'         => 1,
203                   );
204     return $error if $error;
205
206     return {
207       'paynum'        => $cpp->paynum,
208       'paid'          => $cpp->paid,
209       'order_number'  => $transaction->order_number,
210       'auth'          => $transaction->authorization,
211     }
212
213   } else {
214
215     my $error = $gateway->gateway_module. " error: ".
216       $transaction->error_message;
217
218     my $jobnum = $cpp->jobnum;
219     if ( $jobnum ) {
220       my $placeholder = FS::queue->by_key($jobnum);
221
222       if ( $placeholder ) {
223         my $e = $placeholder->depended_delete || $placeholder->delete;
224         warn "error removing provisioning jobs after declined paypendingnum ".
225           $cpp->paypendingnum. ": $e\n\n"
226           if $e;
227       } else {
228         warn "error finding job $jobnum for declined paypendingnum ".
229           $cpp->paypendingnum. "\n\n";
230       }
231     }
232
233     # not needed here:
234     # the raw HTTP response thing when there's no error message
235     # decline notices (the customer has already seen the decline message)
236
237     # set the pending status
238     my $e = $cpp->decline($error);
239     if ( $e ) {
240       $e = "WARNING: payment declined but pending payment not resolved - ".
241            "error updating status for pendingnum :".$cpp->paypendingnum.
242            ": $e\n\n";
243       warn $e;
244       $error = "$e ($error)";
245     }
246
247     return $error;
248   }
249
250 }
251
252 =item cancel_payment SESSION_ID
253
254 Cancel a pending payment attempt.  This just cleans up the cust_pay_pending
255 record.
256
257 =cut
258
259 sub cancel_payment {
260   my $self = shift;
261   my $session_id = shift;
262   my $cust_pay_pending = qsearchs('cust_pay_pending', {
263       'session_id'  => uc($session_id),
264       'status'      => 'thirdparty',
265   });
266   return { 'error' => $cust_pay_pending->delete };
267 }
268
269 1;
270