PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Payments / Idempotency_Repository.php

Idempotency_Repository.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/Payments/Idempotency_Repository.php

228 lines 6.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POS idempotency repository.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\Payments;
9
10 \defined( 'ABSPATH' ) || die;
11
12 use WP_Error;
13
14 /**
15 * Repository for idempotent request replay.
16 *
17 * Stores payloads in transients with a 24 hour TTL.
18 */
19 class Idempotency_Repository {
20 /**
21 * TTL in seconds.
22 */
23 private const TTL = DAY_IN_SECONDS;
24
25 /**
26 * Maximum age for an in-flight claim before it is considered stale.
27 */
28 private const CLAIM_TTL = 5 * MINUTE_IN_SECONDS;
29
30 /**
31 * Find an existing idempotent response.
32 *
33 * @param string $scope Scope name.
34 * @param string $key Idempotency key.
35 */
36 public function find( string $scope, string $key ): ?array {
37 $value = get_transient( $this->transient_key( $scope, $key ) );
38
39 return is_array( $value ) ? $value : null;
40 }
41
42 /**
43 * Store an idempotent response payload.
44 *
45 * @param string $scope Scope name.
46 * @param string $key Idempotency key.
47 * @param string $request_hash Request hash.
48 * @param int $status_code HTTP status code.
49 * @param array $body Response body.
50 */
51 public function store( string $scope, string $key, string $request_hash, int $status_code, array $body ): void {
52 set_transient(
53 $this->transient_key( $scope, $key ),
54 array(
55 'request_hash' => $request_hash,
56 'status_code' => $status_code,
57 'body' => $body,
58 ),
59 self::TTL
60 );
61 }
62
63 /**
64 * Claim an idempotency key for in-flight processing.
65 *
66 * Returns an existing response payload when the request has already been
67 * completed, true when the caller successfully claimed the key, and a
68 * WP_Error when the key conflicts or is currently being processed.
69 *
70 * @param string $scope Scope name.
71 * @param string $key Idempotency key.
72 * @param string $request_hash Request hash.
73 *
74 * @return true|array|WP_Error
75 */
76 public function claim( string $scope, string $key, string $request_hash ) {
77 $existing = $this->find( $scope, $key );
78
79 if ( $existing ) {
80 if ( $existing['request_hash'] !== $request_hash ) {
81 return new WP_Error(
82 'wcpos_idempotency_conflict',
83 /* translators: API conflict error; idempotency key is a request identifier header/token, not a keyboard key. */
84 __( 'Idempotency key was reused with a different request.', 'woocommerce-pos' ),
85 array( 'status' => 409 )
86 );
87 }
88
89 return $existing;
90 }
91
92 $claim_key = $this->claim_key( $scope, $key );
93 $claim = array(
94 'request_hash' => $request_hash,
95 'claimed_at' => time(),
96 );
97
98 if ( $this->try_claim_option( $claim_key, $claim ) ) {
99 return true;
100 }
101
102 $current_claim = get_option( $claim_key );
103 if ( $this->is_stale_claim( $current_claim ) ) {
104 delete_option( $claim_key );
105 $current_claim = get_option( $claim_key );
106 }
107
108 if ( false === $current_claim ) {
109 // @phpstan-ignore-next-line Option creation success depends on live database state.
110 if ( $this->try_claim_option( $claim_key, $claim ) ) {
111 return true;
112 }
113
114 $current_claim = get_option( $claim_key );
115 }
116
117 $existing = $this->find( $scope, $key );
118 if ( $existing ) {
119 if ( $existing['request_hash'] !== $request_hash ) {
120 return new WP_Error(
121 'wcpos_idempotency_conflict',
122 /* translators: API conflict error; idempotency key is a request identifier header/token, not a keyboard key. */
123 __( 'Idempotency key was reused with a different request.', 'woocommerce-pos' ),
124 array( 'status' => 409 )
125 );
126 }
127
128 return $existing;
129 }
130
131 if ( is_array( $current_claim ) && ( $current_claim['request_hash'] ?? null ) !== $request_hash ) {
132 return new WP_Error(
133 'wcpos_idempotency_conflict',
134 /* translators: API conflict error; idempotency key is a request identifier header/token, not a keyboard key. */
135 __( 'Idempotency key was reused with a different request.', 'woocommerce-pos' ),
136 array( 'status' => 409 )
137 );
138 }
139
140 return new WP_Error(
141 'wcpos_idempotency_in_progress',
142 /* translators: API conflict error shown while an identical POS payment request is still running. */
143 __( 'An identical request is already being processed.', 'woocommerce-pos' ),
144 array( 'status' => 409 )
145 );
146 }
147
148 /**
149 * Reject conflicting reuse of an idempotency key.
150 *
151 * @param string $scope Scope name.
152 * @param string $key Idempotency key.
153 * @param string $request_hash Request hash.
154 *
155 * @return true|WP_Error
156 */
157 public function assert_not_conflicting( string $scope, string $key, string $request_hash ) {
158 $existing = $this->find( $scope, $key );
159
160 if ( $existing && $existing['request_hash'] !== $request_hash ) {
161 return new WP_Error(
162 'wcpos_idempotency_conflict',
163 /* translators: API conflict error; idempotency key is a request identifier header/token, not a keyboard key. */
164 __( 'Idempotency key was reused with a different request.', 'woocommerce-pos' ),
165 array( 'status' => 409 )
166 );
167 }
168
169 return true;
170 }
171
172 /**
173 * Release an in-flight claim without storing a replayable response.
174 *
175 * @param string $scope Scope name.
176 * @param string $key Idempotency key.
177 */
178 public function release( string $scope, string $key ): void {
179 delete_option( $this->claim_key( $scope, $key ) );
180 }
181
182 /**
183 * Build transient key.
184 *
185 * @param string $scope Scope name.
186 * @param string $key Idempotency key.
187 */
188 private function transient_key( string $scope, string $key ): string {
189 return 'wcpos_idempotency_' . md5( $scope . '|' . $key );
190 }
191
192 /**
193 * Build in-flight claim option key.
194 *
195 * @param string $scope Scope name.
196 * @param string $key Idempotency key.
197 */
198 private function claim_key( string $scope, string $key ): string {
199 return 'wcpos_idempotency_claim_' . md5( $scope . '|' . $key );
200 }
201
202 /**
203 * Whether the stored claim is stale.
204 *
205 * @param mixed $claim Claim value.
206 */
207 private function is_stale_claim( $claim ): bool {
208 if ( ! is_array( $claim ) ) {
209 return false;
210 }
211
212 $claimed_at = isset( $claim['claimed_at'] ) ? (int) $claim['claimed_at'] : 0;
213
214 return $claimed_at > 0 && ( time() - $claimed_at ) >= self::CLAIM_TTL;
215 }
216
217 /**
218 * Attempt to atomically claim the option row for this key.
219 *
220 * @param string $claim_key Claim option key.
221 * @param array $claim Claim payload.
222 */
223 private function try_claim_option( string $claim_key, array $claim ): bool {
224 // @phpstan-ignore-next-line WordPress option writes are runtime-dependent.
225 return add_option( $claim_key, $claim, '', false );
226 }
227 }
228