| 1 |
<?php |
| 2 |
|
| 3 |
namespace IvyForms\Services\Payment; |
| 4 |
|
| 5 |
use IvyForms\Services\Integrations\MiddlewareRequestAuthService; |
| 6 |
use IvyForms\Services\Integrations\MiddlewareUrl; |
| 7 |
use IvyForms\Services\Settings\SettingsService; |
| 8 |
use IvyForms\Services\Translations\BackendStrings; |
| 9 |
|
| 10 |
/** |
| 11 |
* Square OAuth via IvyForms-hosted middleware (merchant never configures app keys). |
| 12 |
*/ |
| 13 |
class SquareOAuthService |
| 14 |
{ |
| 15 |
private const APPLICATION_ID_TRANSIENT = 'ivyforms_square_application_id'; |
| 16 |
private const SANDBOX_TRANSIENT = 'ivyforms_square_sandbox'; |
| 17 |
|
| 18 |
private SettingsService $settingsService; |
| 19 |
private SquarePaymentService $squarePaymentService; |
| 20 |
private TokenEncryptionService $tokenEncryption; |
| 21 |
private MiddlewareRequestAuthService $middlewareRequestAuthService; |
| 22 |
private SquareMiddlewareClient $squareMiddlewareClient; |
| 23 |
|
| 24 |
public function __construct( |
| 25 |
SettingsService $settingsService, |
| 26 |
SquarePaymentService $squarePaymentService, |
| 27 |
TokenEncryptionService $tokenEncryption, |
| 28 |
MiddlewareRequestAuthService $middlewareRequestAuthService, |
| 29 |
SquareMiddlewareClient $squareMiddlewareClient |
| 30 |
) { |
| 31 |
$this->settingsService = $settingsService; |
| 32 |
$this->squarePaymentService = $squarePaymentService; |
| 33 |
$this->tokenEncryption = $tokenEncryption; |
| 34 |
$this->middlewareRequestAuthService = $middlewareRequestAuthService; |
| 35 |
$this->squareMiddlewareClient = $squareMiddlewareClient; |
| 36 |
} |
| 37 |
|
| 38 |
/** |
| 39 |
* Public Square application ID for Web Payments SDK (from IvyForms middleware, not per-site config). |
| 40 |
*/ |
| 41 |
public function getPublicApplicationId(): string |
| 42 |
{ |
| 43 |
$definedApplicationId = $this->readApplicationIdFromConstant(); |
| 44 |
if ($definedApplicationId !== '') { |
| 45 |
return $definedApplicationId; |
| 46 |
} |
| 47 |
|
| 48 |
$cachedApplicationId = $this->readApplicationIdFromTransient(); |
| 49 |
if ($cachedApplicationId !== '') { |
| 50 |
return $cachedApplicationId; |
| 51 |
} |
| 52 |
|
| 53 |
$config = $this->squareMiddlewareClient->fetchApplicationConfig(); |
| 54 |
$applicationId = $config['applicationId']; |
| 55 |
if ($applicationId !== '') { |
| 56 |
set_transient(self::APPLICATION_ID_TRANSIENT, $applicationId, DAY_IN_SECONDS); |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Filter the public Square application ID (OAuth + Web Payments SDK). |
| 61 |
* |
| 62 |
* @since 1.0.0 |
| 63 |
* |
| 64 |
* @param string $applicationId Square application ID. |
| 65 |
*/ |
| 66 |
return (string) apply_filters('ivyforms/payment/square_application_id', $applicationId); |
| 67 |
} |
| 68 |
|
| 69 |
private function readApplicationIdFromConstant(): string |
| 70 |
{ |
| 71 |
if (!defined('IVYFORMS_SQUARE_APP_ID') || IVYFORMS_SQUARE_APP_ID === '') { |
| 72 |
return ''; |
| 73 |
} |
| 74 |
|
| 75 |
return (string) IVYFORMS_SQUARE_APP_ID; |
| 76 |
} |
| 77 |
|
| 78 |
private function readApplicationIdFromTransient(): string |
| 79 |
{ |
| 80 |
$cached = get_transient(self::APPLICATION_ID_TRANSIENT); |
| 81 |
|
| 82 |
return is_string($cached) ? trim($cached) : ''; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Whether middleware Square credentials target sandbox (from /square/application-config). |
| 87 |
*/ |
| 88 |
public function isMiddlewareSandbox(): bool |
| 89 |
{ |
| 90 |
if (defined('IVYFORMS_SQUARE_AUTH_URL') && IVYFORMS_SQUARE_AUTH_URL !== '') { |
| 91 |
return str_contains((string) IVYFORMS_SQUARE_AUTH_URL, 'sandbox'); |
| 92 |
} |
| 93 |
|
| 94 |
if (defined('IVYFORMS_SQUARE_APP_ID') && IVYFORMS_SQUARE_APP_ID !== '') { |
| 95 |
return str_starts_with((string) IVYFORMS_SQUARE_APP_ID, 'sandbox-sq'); |
| 96 |
} |
| 97 |
|
| 98 |
$cached = get_transient(self::SANDBOX_TRANSIENT); |
| 99 |
if ($cached !== false) { |
| 100 |
return (bool) $cached; |
| 101 |
} |
| 102 |
|
| 103 |
$config = $this->squareMiddlewareClient->fetchApplicationConfig(); |
| 104 |
set_transient(self::SANDBOX_TRANSIENT, !empty($config['sandbox']) ? 1 : 0, DAY_IN_SECONDS); |
| 105 |
|
| 106 |
return !empty($config['sandbox']); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Request Square OAuth URL from ivyforms-middleware (Amelia pattern: CSRF state in middleware DB). |
| 111 |
* |
| 112 |
* @return array{success: bool, url?: string, message?: string} |
| 113 |
*/ |
| 114 |
public function getAuthorizationUrl(): array |
| 115 |
{ |
| 116 |
$callbackUrl = rest_url('ivyforms/v1/integration/square/authorization/token'); |
| 117 |
$authUrl = $this->squareMiddlewareClient->fetchAuthorizationUrl($callbackUrl); |
| 118 |
|
| 119 |
if ($authUrl === null) { |
| 120 |
return [ |
| 121 |
'success' => false, |
| 122 |
'message' => BackendStrings::getPaymentStrings()['square_service_unavailable'], |
| 123 |
]; |
| 124 |
} |
| 125 |
|
| 126 |
return [ |
| 127 |
'success' => true, |
| 128 |
'url' => $authUrl, |
| 129 |
]; |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Persist tokens from middleware server-to-server POST (signed, no browser query params). |
| 134 |
* |
| 135 |
* @param array<string, mixed> $params |
| 136 |
* @return array{success: bool, message?: string} |
| 137 |
*/ |
| 138 |
public function handleAuthCallback(array $params): array |
| 139 |
{ |
| 140 |
$accessToken = $this->sanitizeOAuthToken((string) ($params['access_token'] ?? '')); |
| 141 |
$refreshToken = $this->sanitizeOAuthToken((string) ($params['refresh_token'] ?? '')); |
| 142 |
$merchantId = sanitize_text_field((string) ($params['merchant_id'] ?? '')); |
| 143 |
$expiresAt = sanitize_text_field((string) ($params['expires_at'] ?? '')); |
| 144 |
|
| 145 |
if ($accessToken === '' || $merchantId === '') { |
| 146 |
return [ |
| 147 |
'success' => false, |
| 148 |
'message' => BackendStrings::getPaymentStrings()['square_connect_failed'], |
| 149 |
]; |
| 150 |
} |
| 151 |
|
| 152 |
$encryptedAccess = $this->tokenEncryption->encrypt($accessToken); |
| 153 |
$encryptedRefresh = $refreshToken !== '' |
| 154 |
? $this->tokenEncryption->encrypt($refreshToken) |
| 155 |
: ''; |
| 156 |
|
| 157 |
$this->settingsService->setSetting('payment', 'square', [ |
| 158 |
'connected' => true, |
| 159 |
'merchantId' => $merchantId, |
| 160 |
'accessToken' => $encryptedAccess, |
| 161 |
'refreshToken' => $encryptedRefresh, |
| 162 |
'expiresAt' => $expiresAt, |
| 163 |
'disconnectedByWebhook' => false, |
| 164 |
]); |
| 165 |
|
| 166 |
$location = $this->squarePaymentService->getPrimaryLocationFromApi(); |
| 167 |
$merchant = $this->squarePaymentService->getMerchantInfo(); |
| 168 |
|
| 169 |
$settings = $this->squarePaymentService->getSquareSettings(); |
| 170 |
$settings['merchantName'] = (string) ($merchant['business_name'] ?? $merchant['name'] ?? ''); |
| 171 |
$settings['locationId'] = (string) ($location['id'] ?? ''); |
| 172 |
$settings['locationName'] = (string) ($location['name'] ?? ''); |
| 173 |
$settings['currency'] = (string) ($location['currency'] ?? 'USD'); |
| 174 |
|
| 175 |
$this->settingsService->setSetting('payment', 'square', $settings); |
| 176 |
|
| 177 |
$config = $this->squareMiddlewareClient->fetchApplicationConfig(); |
| 178 |
set_transient(self::SANDBOX_TRANSIENT, !empty($config['sandbox']) ? 1 : 0, DAY_IN_SECONDS); |
| 179 |
|
| 180 |
return ['success' => true]; |
| 181 |
} |
| 182 |
|
| 183 |
public function revokeAccess(): bool |
| 184 |
{ |
| 185 |
$accessToken = $this->squarePaymentService->getDecryptedAccessToken(); |
| 186 |
if ($accessToken === '') { |
| 187 |
return false; |
| 188 |
} |
| 189 |
|
| 190 |
$body = wp_json_encode(['access_token' => $accessToken]); |
| 191 |
if (!is_string($body)) { |
| 192 |
return false; |
| 193 |
} |
| 194 |
|
| 195 |
$revokeUrl = MiddlewareUrl::getBaseUrl() . '/square/revoke'; |
| 196 |
$response = wp_remote_post( |
| 197 |
$revokeUrl, |
| 198 |
[ |
| 199 |
'headers' => $this->middlewareRequestAuthService->buildSignedHeaders($body, $revokeUrl, 'POST'), |
| 200 |
'body' => $body, |
| 201 |
'timeout' => 30, |
| 202 |
] |
| 203 |
); |
| 204 |
|
| 205 |
if (is_wp_error($response)) { |
| 206 |
return false; |
| 207 |
} |
| 208 |
|
| 209 |
$decoded = json_decode((string) wp_remote_retrieve_body($response), true); |
| 210 |
|
| 211 |
return !empty($decoded['result']); |
| 212 |
} |
| 213 |
|
| 214 |
public function disconnect(): void |
| 215 |
{ |
| 216 |
$this->revokeAccess(); |
| 217 |
$this->clearSquareSettings(); |
| 218 |
$this->notifyAdminDisconnected(); |
| 219 |
} |
| 220 |
|
| 221 |
public function clearSquareSettings(): void |
| 222 |
{ |
| 223 |
$this->resetSquareSettings(false); |
| 224 |
} |
| 225 |
|
| 226 |
public function clearSquareSettingsFromRevocationWebhook(): void |
| 227 |
{ |
| 228 |
$this->resetSquareSettings(true); |
| 229 |
} |
| 230 |
|
| 231 |
private function resetSquareSettings(bool $disconnectedByWebhook): void |
| 232 |
{ |
| 233 |
$this->settingsService->setSetting('payment', 'square', [ |
| 234 |
'connected' => false, |
| 235 |
'merchantId' => '', |
| 236 |
'merchantName' => '', |
| 237 |
'locationId' => '', |
| 238 |
'locationName' => '', |
| 239 |
'currency' => '', |
| 240 |
'accessToken' => '', |
| 241 |
'refreshToken' => '', |
| 242 |
'expiresAt' => '', |
| 243 |
'disconnectedByWebhook' => $disconnectedByWebhook, |
| 244 |
]); |
| 245 |
} |
| 246 |
|
| 247 |
private function sanitizeOAuthToken(string $raw): string |
| 248 |
{ |
| 249 |
$token = wp_unslash($raw); |
| 250 |
if ($token === '' || strlen($token) > 4096) { |
| 251 |
return ''; |
| 252 |
} |
| 253 |
|
| 254 |
return $token; |
| 255 |
} |
| 256 |
|
| 257 |
public function notifyAdminDisconnected(): void |
| 258 |
{ |
| 259 |
$adminEmail = get_option('admin_email'); |
| 260 |
if (!is_email($adminEmail)) { |
| 261 |
return; |
| 262 |
} |
| 263 |
|
| 264 |
$subject = BackendStrings::getPaymentStrings()['square_disconnect_email_subject']; |
| 265 |
$message = BackendStrings::getPaymentStrings()['square_disconnect_email_body']; |
| 266 |
|
| 267 |
wp_mail($adminEmail, $subject, $message); |
| 268 |
} |
| 269 |
} |
| 270 |
|