PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / stripe-addon / service / stripe-payment-method.php

stripe-payment-method.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/module/stripe-addon/service/stripe-payment-method.php

311 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Module\Stripe_Addon\Service;
4
5 use Booktics\Models\Order_Model;
6 use Booktics\Module\Stripe_Addon\Handler\Stripe_Handler;
7 use Booktics\Payment\Payment_Methods\Abstract_Payment_Method;
8 use Booktics\Payment\Payment_Status;
9 use Exception;
10 use Stripe\PaymentIntent;
11 use Stripe\Refund;
12 use Stripe\Stripe;
13
14 class Stripe_Payment_Method extends Abstract_Payment_Method {
15
16 /**
17 * Initialize the Stripe SDK with the secret key.
18 *
19 * @return void
20 */
21 private function init_stripe_sdk() {
22 Stripe::setApiKey( Stripe_Handler::get_stripe_secret_key() );
23 }
24
25 /**
26 * Get the Stripe connect account URL.
27 *
28 * @return string The connect URL
29 */
30 public static function connect_account( $type = 'test' ) {
31 return ( new Stripe_Handler() )->get_connect_url( $type );
32 }
33
34 /**
35 * Get the Stripe account ID from settings.
36 *
37 * @return string|null The account ID
38 */
39 public static function account_id() {
40 return booktics_get_option( 'stripe_account_id' );
41 }
42
43 /**
44 * Get the Stripe account type from settings.
45 *
46 * @return string|null The account type
47 */
48 public static function account_type() {
49 return booktics_get_option( 'stripe_account_type', 'test');
50 }
51
52 /**
53 * Process a payment through Stripe.
54 *
55 * @param Order_Model $order The order to process payment for
56 *
57 * @return array The payment processing result
58 */
59 public function process_payment( $order ): array {
60 $this->init_stripe_sdk();
61
62 // Always charge in the store currency. The order currency is not trusted
63 // because orders created before this check could carry a buyer's choice.
64 $currency = booktics_get_store_currency();
65 try {
66 $intent = PaymentIntent::create( [
67 'amount' => $this->expected_amount( $order ),
68 'currency' => $currency,
69 'description' => $order->order_no ?? '',
70 'automatic_payment_methods' => [
71 'enabled' => true,
72 ],
73 'metadata' => [
74 'order_id' => $order->id,
75 // Pins the currency this order is priced in, so a later
76 // store currency change does not fail a genuine payment.
77 'booktics_currency' => $currency,
78 ],
79 ] );
80
81 return [
82 'status' => 'success',
83 'payment_intent' => $intent->id,
84 'client_secret' => $intent->client_secret,
85 ];
86 } catch ( Exception $e ) {
87 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
88 error_log($e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
89 }
90 return [
91 'status' => 'error',
92 'message' => $e->getMessage(),
93 ];
94 }
95 }
96
97 /**
98 * Validate the payment data.
99 *
100 * @return array Validation result
101 */
102 public function validate_payment_data(): array {
103 $client_secret = $this->request_data['client_secret'] ?? null;
104
105 if ( empty( $client_secret ) ) {
106 return [
107 'status' => 'error',
108 'message' => __( 'Missing client_secret for Stripe payment.', 'booktics' )
109 ];
110 }
111
112 return [ 'status' => 'success' ];
113 }
114
115 /**
116 * Get the payment status from Stripe.
117 *
118 * @param Order_Model $order The order to check status for
119 *
120 * @return string The payment status
121 */
122 public function get_payment_status( $order ): string {
123 $this->init_stripe_sdk();
124
125 $payment_intent_id = $order->payment_intent_id ?? null;
126
127 if ( ! $payment_intent_id ) {
128 return Payment_Status::FAILED;
129 }
130
131 try {
132 $intent = PaymentIntent::retrieve( $payment_intent_id );
133
134 // Map Stripe status to Booktics status
135 switch ($intent->status) {
136 case 'succeeded':
137 // A succeeded intent only pays for this order if it charged the
138 // order total in the store currency.
139 return $this->intent_matches_order( $intent, $order )
140 ? Payment_Status::COMPLETED
141 : Payment_Status::FAILED;
142 case 'processing':
143 case 'requires_payment_method':
144 case 'requires_confirmation':
145 case 'requires_action':
146 case 'requires_capture':
147 return Payment_Status::PROCESSING;
148 case 'canceled':
149 return Payment_Status::FAILED;
150 case 'requires_refund':
151 case 'refunded':
152 return Payment_Status::REFUNDED;
153 default:
154 return Payment_Status::FAILED;
155 }
156 } catch ( Exception $e ) {
157 return Payment_Status::FAILED;
158 }
159 }
160
161 /**
162 * Decide the reservation action for a stale unpaid order using Stripe's raw
163 * PaymentIntent lifecycle state.
164 *
165 * Unlike get_payment_status(), which collapses several distinct Stripe states
166 * into "processing", this reads the raw status so a customer who is mid-payment
167 * (e.g. on a bank 3D-Secure / OTP screen) is never canceled underneath them,
168 * even with a short reservation timeout.
169 *
170 * @param Order_Model $order The order whose held slot is being evaluated.
171 *
172 * @return string One of the RESERVATION_* constants.
173 */
174 public function resolve_reservation_action( $order ): string {
175 $this->init_stripe_sdk();
176
177 $payment_intent_id = $order->payment_intent_id ?? null;
178
179 if ( ! $payment_intent_id ) {
180 // No intent was ever created — nothing charged, safe to free the slot.
181 return self::RESERVATION_RELEASE;
182 }
183
184 try {
185 $intent = PaymentIntent::retrieve( $payment_intent_id );
186
187 switch ( $intent->status ) {
188 case 'succeeded':
189 // Money was captured; only the client-side confirmation was lost.
190 // An underpaid intent must not be repaired into a paid booking.
191 return $this->intent_matches_order( $intent, $order )
192 ? self::RESERVATION_REPAIR
193 : self::RESERVATION_RELEASE;
194
195 case 'requires_payment_method':
196 case 'canceled':
197 // Customer never provided a working payment method or the intent
198 // was canceled. The booking is genuinely abandoned — release it.
199 return self::RESERVATION_RELEASE;
200
201 case 'processing':
202 case 'requires_action':
203 case 'requires_confirmation':
204 case 'requires_capture':
205 // Payment is genuinely still in progress (bank verification, async
206 // capture, etc.). Keep the slot and re-check on a later sweep.
207 return self::RESERVATION_WAIT;
208
209 default:
210 // Unknown/unexpected state — do not cancel on uncertainty.
211 return self::RESERVATION_WAIT;
212 }
213 } catch ( Exception $e ) {
214 // Transient API error: err on the safe side and keep the booking held.
215 return self::RESERVATION_WAIT;
216 }
217 }
218
219 /**
220 * Process a refund through Stripe.
221 *
222 * @param Order_Model $order The order to process refund for
223 *
224 * @return array The refund processing result
225 */
226 public function process_refund( $order ) {
227 $this->init_stripe_sdk();
228 $payment_intent_id = $order->payment_intent_id ?? null;
229
230 if ( ! $payment_intent_id ) {
231 return [
232 'status' => 'error',
233 'message' => __( 'Missing payment_intent_id for Stripe payment.', 'booktics' )
234 ];
235 }
236
237 try {
238 // Booktics only issues full refunds, so let Stripe refund whatever the
239 // intent captured, in the currency it was charged. Older orders may
240 // carry a currency that differs from the one actually charged.
241 // No stripe_account: the intent was created on the site's own account.
242 $refund = Refund::create( [ 'payment_intent' => $payment_intent_id ] );
243
244 return [
245 'status' => 'success',
246 'refund_id' => $refund->id,
247 'refund_status' => $refund->status,
248 'message' => __( 'Refund initiated successfully.', 'booktics' ),
249 ];
250 } catch ( \Exception $e ) {
251 return [
252 'status' => 'error',
253 'message' => $e->getMessage(),
254 ];
255 }
256 }
257
258 /**
259 * The amount Stripe should charge for an order, in store currency minor units.
260 *
261 * @param Order_Model $order The order being paid.
262 *
263 * @return int Amount in minor units.
264 */
265 private function expected_amount( $order ): int {
266 return booktics_amount_to_minor_units( $order->total ?? 0, booktics_get_store_currency() );
267 }
268
269 /**
270 * Whether a PaymentIntent captured what the merchant expects for this order.
271 *
272 * @param PaymentIntent $intent The intent retrieved from Stripe.
273 * @param Order_Model $order The order it claims to pay for.
274 *
275 * @return bool True when currency, amount and order all match.
276 */
277 private function intent_matches_order( $intent, $order ): bool {
278 $metadata = $intent->metadata ?? null;
279
280 // Intents created before booktics_currency was recorded may have been
281 // priced in a buyer-chosen currency, so they must match the store's.
282 $currency = ! empty( $metadata['booktics_currency'] )
283 ? strtolower( (string) $metadata['booktics_currency'] )
284 : booktics_get_store_currency();
285
286 $expected = booktics_amount_to_minor_units( $order->total ?? 0, $currency );
287 $received = (int) $intent->amount_received;
288
289 $matches = strtolower( (string) $intent->currency ) === $currency
290 && $received >= $expected
291 && ( empty( $metadata['order_id'] ) || (int) $metadata['order_id'] === (int) $order->id );
292
293 if ( ! $matches ) {
294 // The money is captured at Stripe but the booking is not confirmed;
295 // leave a trail so the merchant can refund it.
296 booktics_log( sprintf(
297 'Stripe intent %s (order %s) does not pay order %d: received %d %s, expected %d %s.',
298 $intent->id ?? '',
299 $metadata['order_id'] ?? '-',
300 $order->id ?? 0,
301 $received,
302 (string) $intent->currency,
303 $expected,
304 $currency
305 ) );
306 }
307
308 return $matches;
309 }
310 }
311