PluginProbe
The Innovative Form Builder – IvyForms / trunk
The Innovative Form Builder – IvyForms vtrunk
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Services / Payment / SquareOrderService.php

SquareOrderService.php in The Innovative Form Builder – IvyForms trunk, at backend/src/Services/Payment/SquareOrderService.php

326 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace IvyForms\Services\Payment;
4
5 use IvyForms\Services\Translations\BackendStrings;
6
7 /**
8 * Square Orders API and Customers API client.
9 *
10 * Handles order creation, cancellation, and customer lookup/creation.
11 * Every payment processed by IvyForms must be backed by a Square order.
12 */
13 class SquareOrderService
14 {
15 private const SQUARE_VERSION = '2024-10-17';
16 private const CUSTOMER_TRANSIENT_PREFIX = 'ivyforms_sq_cust_';
17 private const CUSTOMER_TRANSIENT_TTL = 7 * DAY_IN_SECONDS;
18
19 private SquarePaymentService $squarePaymentService;
20
21 public function __construct(SquarePaymentService $squarePaymentService)
22 {
23 $this->squarePaymentService = $squarePaymentService;
24 }
25
26 /**
27 * Create a Square order before processing payment.
28 *
29 * @return array{success: bool, orderId: string, message: string}
30 */
31 public function createOrder(
32 string $locationId,
33 int $amountCents,
34 string $currency,
35 string $idempotencyKey,
36 string $formName,
37 string $referenceId,
38 ?string $customerId
39 ): array {
40 $accessToken = $this->squarePaymentService->getDecryptedAccessToken();
41 if ($accessToken === '') {
42 return $this->failureResult(BackendStrings::getPaymentStrings()['square_not_connected']);
43 }
44
45 $body = [
46 'idempotency_key' => $idempotencyKey . '-order',
47 'order' => [
48 'location_id' => $locationId,
49 'reference_id' => $referenceId,
50 'line_items' => [
51 [
52 'quantity' => '1',
53 'name' => sprintf('Form Submission - %s', $formName),
54 'base_price_money' => [
55 'amount' => $amountCents,
56 'currency' => strtoupper($currency),
57 ],
58 ],
59 ],
60 ],
61 ];
62
63 if ((string) $customerId !== '') {
64 $body['order']['customer_id'] = $customerId;
65 }
66
67 $response = wp_remote_post(
68 SquareEnvironment::getApiBaseUrl() . '/orders',
69 [
70 'headers' => $this->buildHeaders($accessToken),
71 'body' => wp_json_encode($body),
72 'timeout' => 30,
73 ]
74 );
75
76 if (is_wp_error($response)) {
77 return $this->failureResult($response->get_error_message());
78 }
79
80 $status = (int) wp_remote_retrieve_response_code($response);
81 $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
82
83 if ($status >= 200 && $status < 300 && is_array($decoded)) {
84 $orderId = (string) ($decoded['order']['id'] ?? '');
85 if ($orderId === '') {
86 return $this->failureResult(BackendStrings::getPaymentStrings()['payment_failed']);
87 }
88
89 return ['success' => true, 'orderId' => $orderId, 'message' => ''];
90 }
91
92 return $this->failureResult($this->extractSquareError($decoded));
93 }
94
95 /**
96 * Cancel a Square order to prevent orphaned open orders after a payment failure.
97 *
98 * @return array{success: bool}
99 */
100 public function cancelOrder(string $orderId, string $locationId): array
101 {
102 $accessToken = $this->squarePaymentService->getDecryptedAccessToken();
103 if ($accessToken === '') {
104 return ['success' => false];
105 }
106
107 $version = $this->fetchOrderVersion($orderId, $accessToken);
108 if ($version === null) {
109 return ['success' => false];
110 }
111
112 $body = [
113 'idempotency_key' => wp_generate_uuid4(),
114 'order' => [
115 'location_id' => $locationId,
116 'state' => 'CANCELED',
117 'version' => $version,
118 ],
119 ];
120
121 $response = wp_remote_request(
122 SquareEnvironment::getApiBaseUrl() . '/orders/' . rawurlencode($orderId),
123 [
124 'method' => 'PUT',
125 'headers' => $this->buildHeaders($accessToken),
126 'body' => wp_json_encode($body),
127 'timeout' => 15,
128 ]
129 );
130
131 if (is_wp_error($response)) {
132 error_log('IvyForms: failed to cancel Square order ' . $orderId . ': ' . $response->get_error_message());
133
134 return ['success' => false];
135 }
136
137 $status = (int) wp_remote_retrieve_response_code($response);
138
139 return ['success' => $status >= 200 && $status < 300];
140 }
141
142 private function fetchOrderVersion(string $orderId, string $accessToken): ?int
143 {
144 $response = wp_remote_get(
145 SquareEnvironment::getApiBaseUrl() . '/orders/' . rawurlencode($orderId),
146 [
147 'headers' => $this->buildHeaders($accessToken),
148 'timeout' => 15,
149 ]
150 );
151
152 if (is_wp_error($response)) {
153 error_log('IvyForms: failed to fetch Square order ' . $orderId . ': ' . $response->get_error_message());
154
155 return null;
156 }
157
158 $status = (int) wp_remote_retrieve_response_code($response);
159 $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
160 if ($status < 200 || $status >= 300 || !is_array($decoded)) {
161 return null;
162 }
163
164 $version = $decoded['order']['version'] ?? null;
165
166 return is_numeric($version) ? (int) $version : null;
167 }
168
169 /**
170 * Find an existing Square customer by email, or create one if none exists.
171 * Result is cached as a transient for 7 days to reduce API calls.
172 */
173 public function getOrCreateCustomerFromEmail(string $email): ?string
174 {
175 if ($email === '') {
176 return null;
177 }
178
179 $transientKey = self::CUSTOMER_TRANSIENT_PREFIX . md5($email);
180 $cached = get_transient($transientKey);
181 if ($cached !== false && $cached !== '') {
182 return (string) $cached;
183 }
184
185 $customerId = $this->searchCustomerByEmail($email);
186 if ($customerId === null) {
187 $customerId = $this->createCustomer($email);
188 }
189
190 if ($customerId !== null && $customerId !== '') {
191 set_transient($transientKey, $customerId, self::CUSTOMER_TRANSIENT_TTL);
192 }
193
194 return $customerId;
195 }
196
197 /**
198 * Search Square for an existing customer matching the given email address.
199 */
200 private function searchCustomerByEmail(string $email): ?string
201 {
202 $accessToken = $this->squarePaymentService->getDecryptedAccessToken();
203 if ($accessToken === '') {
204 return null;
205 }
206
207 $body = [
208 'limit' => 1,
209 'query' => [
210 'filter' => [
211 'email_address' => [
212 'exact' => $email,
213 ],
214 ],
215 ],
216 ];
217
218 $response = wp_remote_post(
219 SquareEnvironment::getApiBaseUrl() . '/customers/search',
220 [
221 'headers' => $this->buildHeaders($accessToken),
222 'body' => wp_json_encode($body),
223 'timeout' => 15,
224 ]
225 );
226
227 if (is_wp_error($response)) {
228 return null;
229 }
230
231 $status = (int) wp_remote_retrieve_response_code($response);
232 $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
233
234 if ($status >= 200 && $status < 300 && is_array($decoded)) {
235 $customers = $decoded['customers'] ?? [];
236 if (is_array($customers) && !empty($customers[0]['id'])) {
237 return (string) $customers[0]['id'];
238 }
239 }
240
241 return null;
242 }
243
244 /**
245 * Create a new Square customer from an email address.
246 */
247 private function createCustomer(string $email): ?string
248 {
249 $accessToken = $this->squarePaymentService->getDecryptedAccessToken();
250 if ($accessToken === '') {
251 return null;
252 }
253
254 $body = [
255 'idempotency_key' => wp_generate_uuid4(),
256 'email_address' => $email,
257 ];
258
259 $response = wp_remote_post(
260 SquareEnvironment::getApiBaseUrl() . '/customers',
261 [
262 'headers' => $this->buildHeaders($accessToken),
263 'body' => wp_json_encode($body),
264 'timeout' => 15,
265 ]
266 );
267
268 if (is_wp_error($response)) {
269 return null;
270 }
271
272 $status = (int) wp_remote_retrieve_response_code($response);
273 $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
274
275 if ($status >= 200 && $status < 300 && is_array($decoded)) {
276 $customerId = (string) ($decoded['customer']['id'] ?? '');
277
278 return $customerId !== '' ? $customerId : null;
279 }
280
281 return null;
282 }
283
284 /**
285 * @return array<string, string>
286 */
287 private function buildHeaders(string $accessToken): array
288 {
289 return [
290 'Authorization' => 'Bearer ' . $accessToken,
291 'Content-Type' => 'application/json',
292 'Square-Version' => self::SQUARE_VERSION,
293 ];
294 }
295
296 /**
297 * @param mixed $decoded
298 */
299 private function extractSquareError($decoded): string
300 {
301 $fallback = BackendStrings::getPaymentStrings()['payment_failed'];
302 if (!is_array($decoded) || empty($decoded['errors'][0])) {
303 return $fallback;
304 }
305
306 $firstError = $decoded['errors'][0];
307 if (!empty($firstError['detail'])) {
308 return (string) $firstError['detail'];
309 }
310
311 if (!empty($firstError['code'])) {
312 return (string) $firstError['code'];
313 }
314
315 return $fallback;
316 }
317
318 /**
319 * @return array{success: bool, orderId: string, message: string}
320 */
321 private function failureResult(string $message): array
322 {
323 return ['success' => false, 'orderId' => '', 'message' => $message];
324 }
325 }
326