PluginProbe
MONEI Payments for WooCommerce / trunk
MONEI Payments for WooCommerce vtrunk
7.3.3 7.3.2 7.3.1 7.3.0 7.2.4 7.2.3 7.2.2 7.2.0 7.2.1 7.1.3 2.1.0 3.0.0 3.1.0 3.1.1 4.0.0 4.1.0 4.1.1 4.2.0 4.2.1 5.0 5.1.0 5.1.1 5.1.2 5.2.2 5.2.3 All 87 releases
monei / src / Services / express / ExpressCartBackup.php

ExpressCartBackup.php in MONEI Payments for WooCommerce trunk, at src/Services/express/ExpressCartBackup.php

359 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Save and restore of a shopper's cart around a product page express payment.
4 *
5 * @package Monei
6 */
7
8 namespace Monei\Services\express;
9
10 use WC_Cart;
11 use WC_Monei_Logger;
12 use WC_Session;
13 use Exception;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Keeps the shopper's cart safe while a product page express payment borrows it.
21 *
22 * Express checkout from a product page has to charge for that one product, and the
23 * only cart WooCommerce has is the live one. This class takes a snapshot first, and
24 * puts it back on every way out of the flow.
25 *
26 * ⚠️ This is a deliberate divergence from the reference implementation. Stripe for
27 * WooCommerce 10.8.5 keeps no backup at all: it persists `chosen_shipping_methods`
28 * and calls `empty_cart()` with nothing saved, so a cancelled express payment throws
29 * away whatever the shopper had collected. That is a support ticket, not a pattern.
30 *
31 * The snapshot lives in the WooCommerce session, where WooCommerce already keeps the
32 * cart itself. Backup and cart therefore share a lifetime and cannot drift apart, and
33 * it works the same for guests, who are most of the traffic on a product page. User
34 * meta would need a second code path and a cleanup obligation, and a stale cart
35 * resurfacing in someone's account weeks later is worse than losing a backup when a
36 * session expires — if the session is gone, so is the cart it was protecting.
37 */
38 class ExpressCartBackup {
39
40 /**
41 * Session key holding the snapshot.
42 */
43 const SESSION_KEY = 'monei_express_cart_backup';
44
45 /**
46 * Snapshot format. Bumped when the shape changes, so a snapshot written by an
47 * older plugin version is discarded instead of restored wrongly.
48 */
49 const VERSION = 1;
50
51 /**
52 * Takes a snapshot of the current cart, unless one is already held.
53 *
54 * Never overwrites: a second express attempt while a backup is open would
55 * otherwise snapshot the single express product and lose the real cart.
56 *
57 * @return bool True when a snapshot is held afterwards.
58 */
59 public function save() {
60 $session = $this->get_session();
61
62 if ( ! $session instanceof WC_Session || ! WC()->cart instanceof WC_Cart ) {
63 return false;
64 }
65
66 if ( $this->has_backup() ) {
67 return true;
68 }
69
70 $session->set(
71 self::SESSION_KEY,
72 self::build_snapshot(
73 (array) WC()->cart->get_cart_for_session(),
74 (array) WC()->cart->get_applied_coupons(),
75 (array) $session->get( 'chosen_shipping_methods', array() )
76 )
77 );
78
79 return true;
80 }
81
82 /**
83 * Records which cart items the express flow itself put in the cart.
84 *
85 * Restore keeps everything else, which is how a second browser tab editing the
86 * cart mid-flow survives.
87 *
88 * @param string[] $keys Cart item keys.
89 *
90 * @return void
91 */
92 public function remember_express_items( array $keys ) {
93 $backup = $this->get_backup();
94 $session = $this->get_session();
95
96 if ( null === $backup || ! $session instanceof WC_Session ) {
97 return;
98 }
99
100 $backup['express_keys'] = array_values( array_map( 'strval', $keys ) );
101
102 $session->set( self::SESSION_KEY, $backup );
103 }
104
105 /**
106 * @return bool
107 */
108 public function has_backup() {
109 return null !== $this->get_backup();
110 }
111
112 /**
113 * Puts the shopper's cart back and drops the snapshot.
114 *
115 * @return bool True when the cart was restored, false when there was nothing to
116 * restore or the restore failed.
117 */
118 public function restore() {
119 $backup = $this->get_backup();
120
121 if ( null === $backup ) {
122 return false;
123 }
124
125 $session = $this->get_session();
126 $cart = WC()->cart;
127
128 if ( ! $session instanceof WC_Session || ! $cart instanceof WC_Cart ) {
129 return false;
130 }
131
132 // Anything the shopper has in the cart now that express did not put there came
133 // from somewhere else — a second tab, most likely — and must survive.
134 $foreign = self::foreign_items(
135 (array) $cart->get_cart_for_session(),
136 isset( $backup['express_keys'] ) ? (array) $backup['express_keys'] : array()
137 );
138
139 try {
140 $cart->empty_cart();
141
142 $session->set( 'cart', $backup['contents'] );
143 $session->set( 'applied_coupons', $backup['coupons'] );
144 $session->set( 'chosen_shipping_methods', $backup['shipping'] );
145
146 // Rebuilds the product objects and revalidates every line, which is what
147 // makes a snapshot safe to hold across a session.
148 $cart->get_cart_from_session();
149
150 $this->add_items( $foreign );
151
152 $cart->calculate_totals();
153 } catch ( Exception $e ) {
154 // The snapshot stays in the session on failure, so a later exit path can
155 // try again rather than the shopper being left with whatever is there now.
156 $this->fail( $backup, 'Express checkout could not restore the cart: ' . $e->getMessage() );
157
158 return false;
159 }
160
161 // A snapshot that held items and produced an empty cart is a silent basket
162 // wipe, which is the one outcome this class exists to prevent.
163 if ( ! empty( $backup['contents'] ) && 0 === $cart->get_cart_contents_count() ) {
164 $this->fail( $backup, 'Express checkout restored an empty cart from a snapshot holding ' . count( $backup['contents'] ) . ' item(s).' );
165
166 return false;
167 }
168
169 $session->set( self::SESSION_KEY, null );
170
171 return true;
172 }
173
174 /**
175 * Drops the snapshot without restoring, for when the express order was placed and
176 * the old cart is genuinely finished with.
177 *
178 * @return void
179 */
180 public function forget() {
181 $session = $this->get_session();
182
183 if ( $session instanceof WC_Session ) {
184 $session->set( self::SESSION_KEY, null );
185 }
186 }
187
188 /**
189 * Builds the stored form of a cart.
190 *
191 * `get_cart_for_session()` has already dropped the `WC_Product` object from each
192 * line; this strips any that a filter put back, because a product object in the
193 * session serializes the whole post and rehydrates stale.
194 *
195 * @param array<string, mixed> $contents Cart contents in session form.
196 * @param array<int, string> $coupons Applied coupon codes.
197 * @param array<int|string, mixed> $shipping Chosen shipping methods.
198 *
199 * @return array<string, mixed>
200 */
201 public static function build_snapshot( array $contents, array $coupons, array $shipping ) {
202 $clean = array();
203
204 foreach ( $contents as $key => $item ) {
205 if ( ! is_array( $item ) ) {
206 continue;
207 }
208
209 unset( $item['data'] );
210
211 $clean[ $key ] = $item;
212 }
213
214 return array(
215 'version' => self::VERSION,
216 'created' => time(),
217 'contents' => $clean,
218 'coupons' => array_values( $coupons ),
219 'shipping' => $shipping,
220 'express_keys' => array(),
221 );
222 }
223
224 /**
225 * Cart lines that the express flow did not add.
226 *
227 * @param array<string, mixed> $contents Current cart in session form.
228 * @param string[] $express_keys Keys express added.
229 *
230 * @return array<string, mixed>
231 */
232 public static function foreign_items( array $contents, array $express_keys ) {
233 $foreign = array();
234
235 foreach ( $contents as $key => $item ) {
236 if ( in_array( (string) $key, array_map( 'strval', $express_keys ), true ) ) {
237 continue;
238 }
239
240 if ( is_array( $item ) ) {
241 $foreign[ $key ] = $item;
242 }
243 }
244
245 return $foreign;
246 }
247
248 /**
249 * Whether a value read back out of the session is a snapshot this version wrote.
250 *
251 * @param mixed $backup Value from the session.
252 *
253 * @return bool
254 */
255 public static function is_restorable( $backup ) {
256 return is_array( $backup )
257 && isset( $backup['version'] ) && self::VERSION === $backup['version']
258 && isset( $backup['contents'] ) && is_array( $backup['contents'] )
259 && isset( $backup['coupons'] ) && is_array( $backup['coupons'] )
260 && isset( $backup['shipping'] ) && is_array( $backup['shipping'] );
261 }
262
263 /**
264 * Re-adds lines through the ordinary cart API, so stock and validation still apply.
265 *
266 * @param array<string, mixed> $items Cart lines in session form.
267 *
268 * @return void
269 */
270 private function add_items( array $items ) {
271 foreach ( $items as $item ) {
272 if ( empty( $item['product_id'] ) ) {
273 continue;
274 }
275
276 $extra = $item;
277 unset(
278 $extra['key'],
279 $extra['product_id'],
280 $extra['variation_id'],
281 $extra['variation'],
282 $extra['quantity'],
283 $extra['data'],
284 $extra['data_hash'],
285 $extra['line_tax_data'],
286 $extra['line_subtotal'],
287 $extra['line_subtotal_tax'],
288 $extra['line_total'],
289 $extra['line_tax']
290 );
291
292 WC()->cart->add_to_cart(
293 (int) $item['product_id'],
294 isset( $item['quantity'] ) ? (int) $item['quantity'] : 1,
295 isset( $item['variation_id'] ) ? (int) $item['variation_id'] : 0,
296 isset( $item['variation'] ) && is_array( $item['variation'] ) ? $item['variation'] : array(),
297 $extra
298 );
299 }
300 }
301
302 /**
303 * Never lets a failed restore pass silently: the shopper is told and the failure
304 * is logged.
305 *
306 * ⚠️ Only the shape of the snapshot is logged, never the snapshot. Cart extensions
307 * keep shopper-entered values — gift messages, engraving, custom fields — in line
308 * item metadata, and the log is not the place for those. The snapshot itself stays
309 * in the session, which is where a later exit path recovers it from.
310 *
311 * @param array<string, mixed> $backup Snapshot that failed to restore.
312 * @param string $message Log message.
313 *
314 * @return void
315 */
316 private function fail( array $backup, $message ) {
317 WC_Monei_Logger::log( $message, WC_Monei_Logger::LEVEL_ERROR );
318 WC_Monei_Logger::log(
319 sprintf(
320 'Express checkout snapshot: version %s, %d item(s).',
321 isset( $backup['version'] ) ? (string) $backup['version'] : 'unknown',
322 isset( $backup['contents'] ) ? count( (array) $backup['contents'] ) : 0
323 ),
324 WC_Monei_Logger::LEVEL_ERROR
325 );
326
327 if ( function_exists( 'wc_add_notice' ) ) {
328 wc_add_notice(
329 __( 'We could not put your cart back after the express checkout. Please check your cart before ordering.', 'monei' ),
330 'error'
331 );
332 }
333 }
334
335 /**
336 * @return array<string, mixed>|null
337 */
338 private function get_backup() {
339 $session = $this->get_session();
340
341 if ( ! $session instanceof WC_Session ) {
342 return null;
343 }
344
345 $backup = $session->get( self::SESSION_KEY, null );
346
347 return self::is_restorable( $backup ) ? $backup : null;
348 }
349
350 /**
351 * @return WC_Session|null
352 */
353 private function get_session() {
354 $session = function_exists( 'WC' ) ? WC()->session : null;
355
356 return $session instanceof WC_Session ? $session : null;
357 }
358 }
359