| @@ -5,9 +5,12 @@ | ||
| 5 | 5 | * @package Timetics |
| 6 | 6 | */ |
| 7 | 7 | namespace Timetics\Core\Integrations\Stripe; |
| 8 | 8 | |
| 9 | +defined( 'ABSPATH' ) || exit; | |
| 10 | + | |
| 9 | 11 | use Timetics\Base\Api; |
| 12 | +use Timetics\Core\Bookings\Booking; | |
| 10 | 13 | use Timetics\Utils\Singleton; |
| 11 | 14 | use WP_HTTP_Response; |
| 12 | 15 | |
| 13 | 16 | /** |
| @@ -61,23 +64,91 @@ | ||
| 61 | 64 | * |
| 62 | 65 | * @return JSON |
| 63 | 66 | */ |
| 64 | 67 | public function create_payment( $request ) { |
| 68 | + if ( $this->is_rate_limited() ) { | |
| 69 | + return new WP_HTTP_Response( | |
| 70 | + [ | |
| 71 | + 'success' => 0, | |
| 72 | + 'status_code' => 429, | |
| 73 | + 'message' => __( 'Too many requests. Please try again later.', 'timetics' ), | |
| 74 | + ], | |
| 75 | + 429 | |
| 76 | + ); | |
| 77 | + } | |
| 78 | + | |
| 65 | 79 | $data = json_decode( $request->get_body(), true ); |
| 66 | 80 | |
| 67 | - $amount = ! empty( $data['amount'] ) ? floatval( $data['amount'] ) : 0; | |
| 68 | - $currency = ! empty( $data['currency'] ) ? sanitize_text_field( $data['currency'] ) : ''; | |
| 81 | + $amount = ! empty( $data['amount'] ) ? floatval( $data['amount'] ) : 0; | |
| 82 | + $currency = ! empty( $data['currency'] ) ? sanitize_text_field( $data['currency'] ) : ''; | |
| 83 | + $booking_id = ! empty( $data['booking_id'] ) ? absint( $data['booking_id'] ) : 0; | |
| 84 | + $token = ! empty( $data['security_token'] ) ? sanitize_text_field( $data['security_token'] ) : ''; | |
| 69 | 85 | |
| 86 | + $metadata = []; | |
| 87 | + | |
| 88 | + // The card form (StripePayment.js) creates this PaymentIntent up front, before | |
| 89 | + // a booking exists, purely from the meeting's price — so booking_id/token are | |
| 90 | + // optional here. Binding happens later via bind_payment_intent(), and the booking | |
| 91 | + // can only be marked paid there after its security_token is verified. An intent | |
| 92 | + // created without a booking can never complete a payment, so this cannot be used | |
| 93 | + // to steal funds; it can only let a caller create inert PaymentIntents in Stripe. | |
| 94 | + if ( $booking_id > 0 && '' !== $token ) { | |
| 95 | + $booking = new Booking( $booking_id ); | |
| 96 | + | |
| 97 | + if ( ! $booking->is_booking() ) { | |
| 98 | + return new WP_HTTP_Response( | |
| 99 | + [ | |
| 100 | + 'success' => 0, | |
| 101 | + 'status_code' => 404, | |
| 102 | + 'message' => __( 'Invalid booking id.', 'timetics' ), | |
| 103 | + ], | |
| 104 | + 404 | |
| 105 | + ); | |
| 106 | + } | |
| 107 | + | |
| 108 | + $stored = (string) $booking->get_security_token(); | |
| 109 | + | |
| 110 | + if ( '' === $stored || ! hash_equals( $stored, $token ) ) { | |
| 111 | + return new WP_HTTP_Response( | |
| 112 | + [ | |
| 113 | + 'success' => 0, | |
| 114 | + 'status_code' => 403, | |
| 115 | + 'message' => __( 'Invalid booking token.', 'timetics' ), | |
| 116 | + ], | |
| 117 | + 403 | |
| 118 | + ); | |
| 119 | + } | |
| 120 | + | |
| 121 | + $metadata['booking_id'] = $booking_id; | |
| 122 | + $metadata['security_token'] = $stored; | |
| 123 | + | |
| 124 | + // Once bound, trust the booking's own total over whatever the client sent. | |
| 125 | + $amount = (float) $booking->get_total(); | |
| 126 | + } | |
| 127 | + | |
| 128 | + // Sanity bounds — reject nonsense amounts regardless of binding. | |
| 129 | + if ( $amount <= 0 || $amount > 1000000 || ! preg_match( '/^[A-Za-z]{3}$/', (string) $currency ) ) { | |
| 130 | + return new WP_HTTP_Response( | |
| 131 | + [ | |
| 132 | + 'success' => 0, | |
| 133 | + 'status_code' => 400, | |
| 134 | + 'message' => __( 'Invalid amount or currency.', 'timetics' ), | |
| 135 | + ], | |
| 136 | + 400 | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + | |
| 70 | 140 | $payment = new StripePayment(); |
| 71 | 141 | |
| 72 | 142 | $payment = $payment->create_payment( |
| 73 | 143 | [ |
| 74 | - 'amount' => $amount * 100, | |
| 75 | - 'currency' => $currency, | |
| 76 | - ] | |
| 144 | + 'amount' => $amount * 100, | |
| 145 | + 'currency' => $currency, | |
| 146 | + 'metadata' => $metadata, | |
| 147 | + ] | |
| 77 | 148 | ); |
| 78 | 149 | |
| 79 | - if ( is_wp_error( $data ) ) { | |
| 150 | + if ( is_wp_error( $payment ) ) { | |
| 80 | 151 | $response = [ |
| 81 | 152 | 'success' => 0, |
| 82 | 153 | 'status_code' => 403, |
| 83 | 154 | 'message' => $payment->get_error_message(), |
| @@ -85,8 +156,48 @@ | ||
| 85 | 156 | |
| 86 | 157 | return new WP_HTTP_Response( $response, 403 ); |
| 87 | 158 | } |
| 88 | 159 | |
| 160 | + if ( is_array( $payment ) && ! empty( $payment['error'] ) ) { | |
| 161 | + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { | |
| 162 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging is guarded by WP_DEBUG checks. | |
| 163 | + error_log( 'Timetics Stripe payment intent failed: ' . wp_json_encode( $payment['error'] ) ); | |
| 164 | + } | |
| 165 | + | |
| 166 | + return new WP_HTTP_Response( | |
| 167 | + [ | |
| 168 | + 'success' => 0, | |
| 169 | + 'status_code' => 402, | |
| 170 | + 'message' => __( 'We could not start the payment. Please try again.', 'timetics' ), | |
| 171 | + ], | |
| 172 | + 402 | |
| 173 | + ); | |
| 174 | + } | |
| 175 | + | |
| 89 | 176 | return rest_ensure_response( $payment ); |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * Simple per-IP fixed-window limiter for the public payment-intent route. | |
| 181 | + * | |
| 182 | + * @return bool | |
| 183 | + */ | |
| 184 | + private function is_rate_limited() { | |
| 185 | + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; | |
| 186 | + | |
| 187 | + if ( '' === $ip ) { | |
| 188 | + return false; | |
| 189 | + } | |
| 190 | + | |
| 191 | + $key = 'tt_stripe_rl_' . md5( $ip ); | |
| 192 | + $count = (int) get_transient( $key ); | |
| 193 | + | |
| 194 | + if ( $count >= 20 ) { | |
| 195 | + return true; | |
| 196 | + } | |
| 197 | + | |
| 198 | + set_transient( $key, $count + 1, MINUTE_IN_SECONDS * 10 ); | |
| 199 | + | |
| 200 | + return false; | |
| 90 | 201 | } |
| 91 | 202 | } |
| 92 | 203 | |