| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\Classes; |
| 4 |
|
| 5 |
use Better_Payment\Lite\Admin\DB; |
| 6 |
|
| 7 |
/** |
| 8 |
* Exit if accessed directly |
| 9 |
*/ |
| 10 |
if ( ! defined( 'ABSPATH' ) ) { |
| 11 |
exit; |
| 12 |
} |
| 13 |
|
| 14 |
/** |
| 15 |
* Thin Stripe API service over the plugin's global Stripe configuration. |
| 16 |
* |
| 17 |
* Extracted as a reusable, WooCommerce-agnostic entry point so integration |
| 18 |
* modules (the WooCommerce gateway today, future consumers tomorrow) can |
| 19 |
* create Checkout Sessions through the exact same wire protocol the existing |
| 20 |
* surfaces use (see Classes\Actions::better_payment_stripe_get_token()): |
| 21 |
* HTTP Basic auth with the secret key, `Stripe-Version: 2019-05-16`, the |
| 22 |
* legacy `line_items` shape, and a 70s timeout. The existing surfaces are |
| 23 |
* deliberately NOT migrated onto this service — their behavior must not |
| 24 |
* change (they resolve keys per-widget, not globally). |
| 25 |
* |
| 26 |
* @since 2.4.0 |
| 27 |
*/ |
| 28 |
class StripeService { |
| 29 |
|
| 30 |
/** |
| 31 |
* Pinned Stripe API version — must match every existing Better Payment |
| 32 |
* Stripe call so all plugin traffic speaks one protocol version. |
| 33 |
*/ |
| 34 |
const API_VERSION = '2019-05-16'; |
| 35 |
|
| 36 |
/** |
| 37 |
* Stripe API base URL. |
| 38 |
*/ |
| 39 |
const API_BASE = 'https://api.stripe.com/v1'; |
| 40 |
|
| 41 |
/** |
| 42 |
* Resolve the global Stripe keys (Better Payment → Settings → Stripe). |
| 43 |
* |
| 44 |
* Live/test resolution mirrors API\AdminAPI::get_stripe_product_details()'s |
| 45 |
* canonical pattern over the `better_payment_settings` option. |
| 46 |
* |
| 47 |
* @return array{public_key: string, secret_key: string, live_mode: bool} |
| 48 |
*/ |
| 49 |
public static function get_global_keys() { |
| 50 |
$is_live_mode = 'yes' === DB::get_settings( 'better_payment_settings_payment_stripe_live_mode' ); |
| 51 |
|
| 52 |
$public_key = $is_live_mode |
| 53 |
? DB::get_settings( 'better_payment_settings_payment_stripe_live_public' ) |
| 54 |
: DB::get_settings( 'better_payment_settings_payment_stripe_test_public' ); |
| 55 |
$secret_key = $is_live_mode |
| 56 |
? DB::get_settings( 'better_payment_settings_payment_stripe_live_secret' ) |
| 57 |
: DB::get_settings( 'better_payment_settings_payment_stripe_test_secret' ); |
| 58 |
|
| 59 |
return array( |
| 60 |
'public_key' => is_string( $public_key ) ? trim( $public_key ) : '', |
| 61 |
'secret_key' => is_string( $secret_key ) ? trim( $secret_key ) : '', |
| 62 |
'live_mode' => $is_live_mode, |
| 63 |
); |
| 64 |
} |
| 65 |
|
| 66 |
/** |
| 67 |
* Whether the global Stripe configuration is usable (both keys present |
| 68 |
* for the currently selected mode). |
| 69 |
* |
| 70 |
* @return bool |
| 71 |
*/ |
| 72 |
public static function is_configured() { |
| 73 |
$keys = self::get_global_keys(); |
| 74 |
|
| 75 |
return ! empty( $keys['public_key'] ) && ! empty( $keys['secret_key'] ); |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Create a Stripe Checkout Session. |
| 80 |
* |
| 81 |
* Same request mechanics as the existing surfaces (Basic auth header, |
| 82 |
* pinned API version, form-encoded body, 70s timeout). The caller owns |
| 83 |
* the request array shape; this method owns only the transport. |
| 84 |
* |
| 85 |
* @param mixed $session_args Checkout Session request parameters (array). |
| 86 |
* @param string $secret_key Stripe secret key. |
| 87 |
* |
| 88 |
* @return object|\WP_Error Decoded session object on success. |
| 89 |
*/ |
| 90 |
public static function create_checkout_session( $session_args, $secret_key ) { |
| 91 |
if ( empty( $secret_key ) || ! is_array( $session_args ) ) { |
| 92 |
return new \WP_Error( 'stripe_error', __( 'Stripe is not configured.', 'better-payment' ) ); |
| 93 |
} |
| 94 |
|
| 95 |
$response = wp_safe_remote_post( |
| 96 |
self::API_BASE . '/checkout/sessions', |
| 97 |
array( |
| 98 |
'method' => 'POST', |
| 99 |
'headers' => array( |
| 100 |
'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $secret_key ) . ':' ), |
| 101 |
'Stripe-Version' => self::API_VERSION, |
| 102 |
), |
| 103 |
'body' => $session_args, |
| 104 |
'timeout' => 70, |
| 105 |
) |
| 106 |
); |
| 107 |
|
| 108 |
if ( is_wp_error( $response ) ) { |
| 109 |
return $response; |
| 110 |
} |
| 111 |
|
| 112 |
$body = wp_remote_retrieve_body( $response ); |
| 113 |
|
| 114 |
if ( empty( $body ) ) { |
| 115 |
return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) ); |
| 116 |
} |
| 117 |
|
| 118 |
$session = json_decode( $body ); |
| 119 |
|
| 120 |
if ( ! is_object( $session ) ) { |
| 121 |
return new \WP_Error( 'stripe_error', __( 'Unexpected response from the Stripe API endpoint.', 'better-payment' ) ); |
| 122 |
} |
| 123 |
|
| 124 |
if ( isset( $session->error ) ) { |
| 125 |
$message = ! empty( $session->error->message ) |
| 126 |
? sanitize_text_field( $session->error->message ) |
| 127 |
: __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ); |
| 128 |
|
| 129 |
return new \WP_Error( 'stripe_error', $message, self::error_details( $session->error ) ); |
| 130 |
} |
| 131 |
|
| 132 |
if ( empty( $session->id ) ) { |
| 133 |
return new \WP_Error( 'stripe_error', __( 'Unexpected response from the Stripe API endpoint.', 'better-payment' ) ); |
| 134 |
} |
| 135 |
|
| 136 |
return $session; |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Retrieve a Checkout Session, optionally expanding related objects. |
| 141 |
* |
| 142 |
* Added for the WooCommerce subscriptions feature: after a verified |
| 143 |
* subscription checkout the module retrieves the session with |
| 144 |
* `expand[]=payment_intent` to learn the reusable customer + payment |
| 145 |
* method Stripe attached. Same transport mechanics as |
| 146 |
* create_checkout_session() (Basic auth, pinned API version, 70s |
| 147 |
* timeout); that method keeps its own inline handling untouched. |
| 148 |
* |
| 149 |
* @since 2.4.0 |
| 150 |
* |
| 151 |
* @param mixed $session_id Checkout Session id (cs_...). |
| 152 |
* @param string $secret_key Stripe secret key. |
| 153 |
* @param mixed $expand Object paths to expand (e.g. array( 'payment_intent' )). |
| 154 |
* |
| 155 |
* @return object|\WP_Error Decoded session object on success. |
| 156 |
*/ |
| 157 |
public static function retrieve_checkout_session( $session_id, $secret_key, $expand = array() ) { |
| 158 |
if ( empty( $secret_key ) || empty( $session_id ) || ! is_string( $session_id ) ) { |
| 159 |
return new \WP_Error( 'stripe_error', __( 'Stripe is not configured.', 'better-payment' ) ); |
| 160 |
} |
| 161 |
|
| 162 |
$url = self::API_BASE . '/checkout/sessions/' . rawurlencode( sanitize_text_field( $session_id ) ); |
| 163 |
|
| 164 |
if ( ! empty( $expand ) && is_array( $expand ) ) { |
| 165 |
$url = add_query_arg( array( 'expand' => array_map( 'sanitize_text_field', array_values( $expand ) ) ), $url ); |
| 166 |
} |
| 167 |
|
| 168 |
$response = wp_safe_remote_get( |
| 169 |
$url, |
| 170 |
array( |
| 171 |
'headers' => array( |
| 172 |
'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $secret_key ) . ':' ), |
| 173 |
'Stripe-Version' => self::API_VERSION, |
| 174 |
), |
| 175 |
'timeout' => 70, |
| 176 |
) |
| 177 |
); |
| 178 |
|
| 179 |
return self::decode_object_response( $response ); |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Create a PaymentIntent. |
| 184 |
* |
| 185 |
* Added for the WooCommerce subscriptions feature: renewal orders are |
| 186 |
* charged server-side with an off-session PaymentIntent against the |
| 187 |
* customer + payment method saved at the first checkout. The caller owns |
| 188 |
* the request array shape; this method owns only the transport. |
| 189 |
* |
| 190 |
* @since 2.4.0 |
| 191 |
* |
| 192 |
* @param mixed $intent_args PaymentIntent request parameters (array). |
| 193 |
* @param string $secret_key Stripe secret key. |
| 194 |
* |
| 195 |
* @return object|\WP_Error Decoded PaymentIntent object on success. |
| 196 |
*/ |
| 197 |
public static function create_payment_intent( $intent_args, $secret_key ) { |
| 198 |
if ( empty( $secret_key ) || ! is_array( $intent_args ) ) { |
| 199 |
return new \WP_Error( 'stripe_error', __( 'Stripe is not configured.', 'better-payment' ) ); |
| 200 |
} |
| 201 |
|
| 202 |
$response = wp_safe_remote_post( |
| 203 |
self::API_BASE . '/payment_intents', |
| 204 |
array( |
| 205 |
'method' => 'POST', |
| 206 |
'headers' => array( |
| 207 |
'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $secret_key ) . ':' ), |
| 208 |
'Stripe-Version' => self::API_VERSION, |
| 209 |
), |
| 210 |
'body' => $intent_args, |
| 211 |
'timeout' => 70, |
| 212 |
) |
| 213 |
); |
| 214 |
|
| 215 |
return self::decode_object_response( $response ); |
| 216 |
} |
| 217 |
|
| 218 |
/** |
| 219 |
* Create a Stripe Customer. |
| 220 |
* |
| 221 |
* Added for the WooCommerce subscriptions feature's free-trial checkout: |
| 222 |
* a $0 order collects the card through a setup-mode Checkout Session, |
| 223 |
* and a setup session only attaches the saved payment method to a |
| 224 |
* customer the caller supplies — so one is created up front. The caller |
| 225 |
* owns the request array shape; this method owns only the transport. |
| 226 |
* |
| 227 |
* @since 2.4.0 |
| 228 |
* |
| 229 |
* @param mixed $customer_args Customer request parameters (array). |
| 230 |
* @param string $secret_key Stripe secret key. |
| 231 |
* |
| 232 |
* @return object|\WP_Error Decoded Customer object on success. |
| 233 |
*/ |
| 234 |
public static function create_customer( $customer_args, $secret_key ) { |
| 235 |
if ( empty( $secret_key ) || ! is_array( $customer_args ) ) { |
| 236 |
return new \WP_Error( 'stripe_error', __( 'Stripe is not configured.', 'better-payment' ) ); |
| 237 |
} |
| 238 |
|
| 239 |
$response = wp_safe_remote_post( |
| 240 |
self::API_BASE . '/customers', |
| 241 |
array( |
| 242 |
'method' => 'POST', |
| 243 |
'headers' => array( |
| 244 |
'Authorization' => 'Basic ' . base64_encode( sanitize_text_field( $secret_key ) . ':' ), |
| 245 |
'Stripe-Version' => self::API_VERSION, |
| 246 |
), |
| 247 |
'body' => $customer_args, |
| 248 |
'timeout' => 70, |
| 249 |
) |
| 250 |
); |
| 251 |
|
| 252 |
return self::decode_object_response( $response ); |
| 253 |
} |
| 254 |
|
| 255 |
/** |
| 256 |
* Decode a Stripe API response into an object, normalizing transport |
| 257 |
* failures and Stripe error payloads into WP_Error. Shared by the |
| 258 |
* transports added for subscriptions. |
| 259 |
* |
| 260 |
* @since 2.4.0 |
| 261 |
* |
| 262 |
* @param array|\WP_Error $response wp_safe_remote_* result. |
| 263 |
* @return object|\WP_Error |
| 264 |
*/ |
| 265 |
private static function decode_object_response( $response ) { |
| 266 |
if ( is_wp_error( $response ) ) { |
| 267 |
return $response; |
| 268 |
} |
| 269 |
|
| 270 |
$body = wp_remote_retrieve_body( $response ); |
| 271 |
|
| 272 |
if ( empty( $body ) ) { |
| 273 |
return new \WP_Error( 'stripe_error', __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ) ); |
| 274 |
} |
| 275 |
|
| 276 |
$object = json_decode( $body ); |
| 277 |
|
| 278 |
if ( ! is_object( $object ) ) { |
| 279 |
return new \WP_Error( 'stripe_error', __( 'Unexpected response from the Stripe API endpoint.', 'better-payment' ) ); |
| 280 |
} |
| 281 |
|
| 282 |
if ( isset( $object->error ) ) { |
| 283 |
$message = ! empty( $object->error->message ) |
| 284 |
? sanitize_text_field( $object->error->message ) |
| 285 |
: __( 'There was a problem connecting to the Stripe API endpoint.', 'better-payment' ); |
| 286 |
|
| 287 |
// The error code/type travels as WP_Error DATA, never as its code: |
| 288 |
// every existing consumer matches on 'stripe_error' and on the |
| 289 |
// message. Callers that must tell a declined card from a |
| 290 |
// mis-configured Stripe account read it from here (see |
| 291 |
// WooCommerce\Subscriptions::renewal_failure_is_configuration()). |
| 292 |
return new \WP_Error( 'stripe_error', $message, self::error_details( $object->error ) ); |
| 293 |
} |
| 294 |
|
| 295 |
if ( empty( $object->id ) ) { |
| 296 |
return new \WP_Error( 'stripe_error', __( 'Unexpected response from the Stripe API endpoint.', 'better-payment' ) ); |
| 297 |
} |
| 298 |
|
| 299 |
return $object; |
| 300 |
} |
| 301 |
|
| 302 |
/** |
| 303 |
* Pure: the `type`/`code`/`decline_code` of a Stripe error payload, as |
| 304 |
* the WP_Error data array. Keys are always present (empty strings when |
| 305 |
* Stripe sent none), so a consumer never has to isset() its way through. |
| 306 |
* |
| 307 |
* @since 2.4.0 |
| 308 |
* |
| 309 |
* @param mixed $error Stripe error object. |
| 310 |
* @return array `type`, `code`, `decline_code`. |
| 311 |
*/ |
| 312 |
public static function error_details( $error ) { |
| 313 |
$read = static function ( $key ) use ( $error ) { |
| 314 |
return is_object( $error ) && isset( $error->{$key} ) && is_scalar( $error->{$key} ) |
| 315 |
? sanitize_text_field( (string) $error->{$key} ) |
| 316 |
: ''; |
| 317 |
}; |
| 318 |
|
| 319 |
return array( |
| 320 |
'type' => $read( 'type' ), |
| 321 |
'code' => $read( 'code' ), |
| 322 |
'decline_code' => $read( 'decline_code' ), |
| 323 |
); |
| 324 |
} |
| 325 |
} |
| 326 |
|