PluginProbe
Bookit — Booking & Appointment Calendar / 2.6.0.5
Bookit — Booking & Appointment Calendar v2.6.0.5
2.6.0.5 2.6.0.4 2.6.0.3 2.6.0.2 2.6.0.1 2.6.0 trunk 1.2 1.2.2 1.2.3 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 All 62 releases
bookit / includes / classes / payments / PayPal.php

PayPal.php in Bookit — Booking & Appointment Calendar 2.6.0.5, at includes/classes/payments/PayPal.php

265 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Bookit\Classes\Payments;
4
5 use Bookit\Classes\Database\Payments as PaymentDb;
6 use Bookit\Helpers\SerializationHelper;
7
8 class PayPal {
9
10 public $url;
11 public $currency_code;
12 public $email;
13 public $return_url;
14 public $invoice;
15 public $amount;
16 public $item_name;
17 public $item_number;
18 public $user_email;
19
20 /**
21 * PayPal constructor.
22 *
23 * @param int $amount
24 * @param int $invoice
25 * @param string $item_name
26 * @param string $item_number
27 * @param string $user_email
28 * @param string $return_url
29 */
30 public function __construct( $amount = 10, $invoice = 0, $item_name = '', $item_number = '', $user_email = '', $return_url = '' ) {
31 $settings = get_option( 'bookit_settings' );
32 $payments = $settings['payments'];
33
34 if ( ! empty( $payments['paypal'] ) && ! empty( $payments['paypal']['enabled'] ) ) {
35 $paypal = $payments['paypal'];
36
37 $this->url = ( 'live' == $paypal['mode'] ) ? 'www.paypal.com' : 'www.sandbox.paypal.com';
38 $this->currency_code = $settings['currency'];
39 $this->email = $paypal['email'];
40 $this->invoice = $invoice;
41 $this->amount = $amount;
42 $this->item_name = $item_name;
43 $this->item_number = $item_number;
44 $this->user_email = $user_email;
45 $this->return_url = apply_filters( 'bookit_paypal_return_url', empty( $return_url ) ? home_url() : $return_url );
46 }
47 }
48
49 /**
50 * Generate Payment URL
51 * @return string
52 */
53 public function generate_payment_url() {
54 $get_params = array(
55 'cmd' => '_xclick',
56 'business' => $this->email,
57 'no_shipping' => 1,
58 'no_note' => 1,
59 'currency_code' => strtoupper( $this->currency_code ),
60 'bn' => 'PP%2dBuyNowBF',
61 'charset' => 'UTF%2d8',
62 'item_name' => $this->item_name,
63 'item_number' => $this->item_number,
64 'invoice' => $this->invoice,
65 'return' => $this->return_url,
66 'email' => $this->user_email,
67 'rm' => 2,
68 'amount' => $this->amount,
69 'notify_url' => get_home_url() . '/?stm_bookit_check_ipn=1',
70 );
71
72 $url = 'https://' . $this->url . '/cgi-bin/webscr?' . http_build_query( $get_params );
73
74 return $url;
75 }
76
77 /**
78 * Check IPN Response
79 *
80 * @since 2.6.0.3 Verify the notification against the stored order before trusting it.
81 *
82 * @param array $ipn_response
83 */
84 public function check_payment( $ipn_response ) {
85 $paypal_adress = 'https://' . $this->url . '/cgi-bin/webscr';
86 $validate_ipn = array( 'cmd' => '_notify-validate' );
87 $validate_ipn += stripslashes_deep( $ipn_response );
88
89 $params = array(
90 'body' => $validate_ipn,
91 'sslverify' => true,
92 'timeout' => 60,
93 'httpversion' => '1.1',
94 'compress' => false,
95 'decompress' => false,
96 'user-agent' => 'paypal-ipn/',
97 );
98
99 $response = wp_safe_remote_post( $paypal_adress, $params );
100
101 $verified = ! is_wp_error( $response )
102 && $response['response']['code'] >= 200 && $response['response']['code'] < 300
103 && strstr( $response['body'], 'VERIFIED' );
104
105 if ( $verified ) {
106 $this->apply_verified_notification( $ipn_response );
107 }
108
109 header( 'HTTP/1.1 200 OK' );
110 exit;
111 }
112
113 /**
114 * Complete, reject, or leave pending the payment for a PayPal-verified
115 * notification. A missing invoice, or one already marked complete
116 * (duplicate delivery), is left untouched.
117 *
118 * @since 2.6.0.3
119 *
120 * @param array $ipn_response
121 */
122 private function apply_verified_notification( $ipn_response ) {
123 if ( empty( $ipn_response['invoice'] ) ) {
124 return;
125 }
126
127 $payment = PaymentDb::get( 'appointment_id', $ipn_response['invoice'] );
128
129 if ( empty( $payment ) || PaymentDb::$completeStatus === $payment->status ) {
130 return;
131 }
132
133 $data = array(
134 'transaction' => $ipn_response['txn_id'],
135 'notes' => serialize( $ipn_response ),
136 'updated_at' => wp_date( 'Y-m-d H:i:s' ),
137 );
138
139 $where = array( 'id' => (int) $payment->id );
140
141 if ( $this->order_mismatched( $ipn_response, $payment->total ) ) {
142 $data['status'] = PaymentDb::$rejectedStatus;
143 PaymentDb::update( $data, $where );
144
145 return;
146 }
147
148 if ( $this->matches_stored_order( $ipn_response, $payment ) ) {
149 $data['status'] = PaymentDb::$completeStatus;
150 $data['paid_at'] = wp_date( 'Y-m-d H:i:s' );
151
152 PaymentDb::update( $data, $where );
153
154 do_action( 'bookit_payment_complete', $ipn_response['invoice'] );
155
156 return;
157 }
158
159 if ( isset( $ipn_response['payment_status'] ) && 'Pending' === $ipn_response['payment_status'] ) {
160 $data['status'] = PaymentDb::$defaultStatus;
161 PaymentDb::update( $data, $where );
162
163 return;
164 }
165
166 $data['status'] = PaymentDb::$rejectedStatus;
167 PaymentDb::update( $data, $where );
168 }
169
170 /**
171 * Confirm a notification matches the stored order for its invoice, and
172 * was not already used to complete a different payment.
173 *
174 * @since 2.6.0.3
175 *
176 * @param array $ipn_response
177 * @param object $payment Row from wp_bookit_payments.
178 * @return bool
179 */
180 private function matches_stored_order( $ipn_response, $payment ) {
181 if ( empty( $ipn_response['txn_id'] ) ) {
182 return false;
183 }
184
185 $used_txn = PaymentDb::get( 'transaction', $ipn_response['txn_id'] );
186 if ( ! empty( $used_txn ) && PaymentDb::$completeStatus === $used_txn->status ) {
187 return false;
188 }
189
190 $status_ok = isset( $ipn_response['payment_status'] ) && 'Completed' === $ipn_response['payment_status'];
191
192 return $status_ok && ! $this->order_mismatched( $ipn_response, $payment->total );
193 }
194
195 /**
196 * Whether a rejected payment was rejected specifically because the
197 * stored IPN's currency, receiver, or amount disagreed with the order
198 * — as opposed to any other rejection reason (e.g. PayPal itself
199 * denying/voiding the transaction).
200 *
201 * @since 2.6.0.3
202 *
203 * @param object $payment Row from wp_bookit_payments (needs ->total and ->notes).
204 * @return bool
205 */
206 public function is_payment_mismatch( $payment ) {
207 if ( empty( $payment->notes ) ) {
208 return false;
209 }
210
211 $ipn_response = SerializationHelper::safe_unserialize( trim( $payment->notes ) );
212 if ( false === $ipn_response ) {
213 return false;
214 }
215
216 return $this->order_mismatched( $ipn_response, $payment->total );
217 }
218
219 /**
220 * Whether the notification's currency, receiver, or amount disagree
221 * with the stored order, regardless of payment status.
222 *
223 * @since 2.6.0.3
224 *
225 * @param array $ipn_response
226 * @param string|float $total
227 * @return bool
228 */
229 private function order_mismatched( $ipn_response, $total ) {
230 $currency_ok = isset( $ipn_response['mc_currency'] ) && strtoupper( $ipn_response['mc_currency'] ) === strtoupper( (string) $this->currency_code );
231 $receiver_ok = $this->matches_configured_receiver( $ipn_response );
232 $amount_ok = isset( $ipn_response['mc_gross'] ) && $this->amounts_match( $ipn_response['mc_gross'], $total );
233
234 return ! $currency_ok || ! $receiver_ok || ! $amount_ok;
235 }
236
237 /**
238 * Confirm the IPN's receiver/business matches the configured PayPal account.
239 *
240 * @since 2.6.0.3
241 *
242 * @param array $ipn_response
243 * @return bool
244 */
245 private function matches_configured_receiver( $ipn_response ) {
246 $receiver = ! empty( $ipn_response['receiver_email'] ) ? $ipn_response['receiver_email'] : ( $ipn_response['business'] ?? '' );
247
248 return ! empty( $receiver ) && strtolower( trim( $receiver ) ) === strtolower( trim( (string) $this->email ) );
249 }
250
251 /**
252 * Strict decimal comparison so a mismatched amount can't slip through as
253 * a float-precision false positive.
254 *
255 * @since 2.6.0.3
256 *
257 * @param string|float $mc_gross
258 * @param string|float $total
259 * @return bool
260 */
261 private function amounts_match( $mc_gross, $total ) {
262 return number_format( (float) $mc_gross, 2, '.', '' ) === number_format( (float) $total, 2, '.', '' );
263 }
264 }
265