PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / account-mover.php

account-mover.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/account-mover.php

306 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Account Mover.
4 *
5 * Non-destructively transfers a customer's StoreEngine data (orders, subscriptions,
6 * downloads, payment tokens, etc.) from one WordPress user to another. Neither user
7 * account is deleted — only the ownership of the records changes.
8 *
9 * The transfer is additive: records from the source user are merged into the target,
10 * and any records the target already owns are preserved.
11 *
12 * @package StoreEngine\Classes
13 */
14
15 namespace StoreEngine\Classes;
16
17 use StoreEngine\SqlTransaction;
18 use StoreEngine\Utils\Helper;
19 use WP_Error;
20 use WP_User;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 class AccountMover {
27
28 /**
29 * Source user ID (data is moved FROM here).
30 *
31 * @var int
32 */
33 protected int $from;
34
35 /**
36 * Target user ID (data is moved TO here).
37 *
38 * @var int
39 */
40 protected int $to;
41
42 /**
43 * @param int $from Source user ID.
44 * @param int $to Target user ID.
45 */
46 public function __construct( int $from, int $to ) {
47 $this->from = $from;
48 $this->to = $to;
49 }
50
51 /**
52 * Core (free) tables owned by a customer, keyed by the customer column.
53 *
54 * Each entry: table suffix (without $wpdb->prefix) => owner column.
55 * Pro addons register their own tables via the `storeengine/account/moveable_tables`
56 * filter and act on the `storeengine/account/moved` hook.
57 *
58 * NOTE: POS operator tables, vendor/affiliate identities, api_keys and the
59 * session-bound cart are intentionally excluded — they are not customer purchases.
60 *
61 * @return array<string, string>
62 */
63 protected function get_moveable_tables(): array {
64 $tables = [
65 'storeengine_orders' => 'customer_id',
66 'storeengine_order_product_lookup' => 'customer_id',
67 'storeengine_subscriptions' => 'user_id',
68 'storeengine_downloadable_product_permissions' => 'user_id',
69 'storeengine_download_log' => 'user_id',
70 'storeengine_payment_tokens' => 'user_id',
71 'storeengine_email_log' => 'customer_id',
72 ];
73
74 /**
75 * Filters the list of tables whose ownership is transferred on account move.
76 *
77 * @param array<string, string> $tables Map of table suffix => owner column.
78 * @param int $from Source user ID.
79 * @param int $to Target user ID.
80 */
81 $tables = apply_filters( 'storeengine/account/moveable_tables', $tables, $this->from, $this->to );
82
83 // Only operate on tables that actually exist (some are addon-created).
84 return array_filter( $tables, [ $this, 'table_exists' ], ARRAY_FILTER_USE_KEY );
85 }
86
87 /**
88 * Whether a StoreEngine table exists.
89 *
90 * @param string $table Table suffix (without $wpdb->prefix).
91 */
92 protected function table_exists( string $table ): bool {
93 global $wpdb;
94
95 $full = $wpdb->prefix . $table;
96
97 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
98 return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $full ) ) );
99 }
100
101 /**
102 * Validate that the move can be performed.
103 *
104 * @return true|WP_Error
105 */
106 public function validate() {
107 if ( $this->from === $this->to ) {
108 return new WP_Error( 'storeengine_account_move_same_user', __( 'Source and target accounts must be different.', 'storeengine' ), [ 'status' => 400 ] );
109 }
110
111 $from_user = get_userdata( $this->from );
112 $to_user = get_userdata( $this->to );
113
114 if ( ! $from_user instanceof WP_User ) {
115 return new WP_Error( 'storeengine_account_move_invalid_source', __( 'Source account does not exist.', 'storeengine' ), [ 'status' => 404 ] );
116 }
117
118 if ( ! $to_user instanceof WP_User ) {
119 return new WP_Error( 'storeengine_account_move_invalid_target', __( 'Target account does not exist.', 'storeengine' ), [ 'status' => 404 ] );
120 }
121
122 return true;
123 }
124
125 /**
126 * Preview how many records would move, per table.
127 *
128 * @return array<string, int>|WP_Error Map of table suffix => row count.
129 */
130 public function preview() {
131 $valid = $this->validate();
132 if ( is_wp_error( $valid ) ) {
133 return $valid;
134 }
135
136 global $wpdb;
137 $counts = [];
138
139 foreach ( $this->get_moveable_tables() as $table => $column ) {
140 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared (%d) count on a custom StoreEngine table; table/column names are internal map keys, not user input.
141 $counts[ $table ] = (int) $wpdb->get_var(
142 $wpdb->prepare(
143 "SELECT COUNT(*) FROM {$wpdb->prefix}{$table} WHERE `{$column}` = %d",
144 $this->from
145 )
146 );
147 // phpcs:enable
148 }
149
150 /**
151 * Filters the account-move preview counts. Pro addons append their own entries.
152 *
153 * @param array<string, int> $counts Map of table suffix => row count for the source user.
154 * @param int $from Source user ID.
155 * @param int $to Target user ID.
156 */
157 return apply_filters( 'storeengine/account/move_preview', $counts, $this->from, $this->to );
158 }
159
160 /**
161 * Perform the move inside a single DB transaction.
162 *
163 * @param array $options Reserved for future use (e.g. selective entities).
164 *
165 * @return array|WP_Error Result summary: [ 'moved' => [table => rows], 'from' => id, 'to' => id ].
166 */
167 public function move( array $options = [] ) {
168 $valid = $this->validate();
169 if ( is_wp_error( $valid ) ) {
170 return $valid;
171 }
172
173 global $wpdb;
174
175 // Capture subscription order IDs before the move so downstream listeners
176 // (e.g. the subscription addon) can flag them for payment re-authorization —
177 // the moved payment tokens belong to the source user's gateway customer.
178 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
179 $subscription_ids = array_map( 'intval', (array) $wpdb->get_col(
180 $wpdb->prepare(
181 "SELECT id FROM {$wpdb->prefix}storeengine_orders WHERE customer_id = %d AND type = 'subscription'",
182 $this->from
183 )
184 ) );
185
186 $transaction = new SqlTransaction();
187 $transaction->start();
188
189 try {
190 $moved = [];
191
192 foreach ( $this->get_moveable_tables() as $table => $column ) {
193 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared (%d) update on a custom StoreEngine table; table/column names are internal map keys, not user input.
194 $result = $wpdb->query(
195 $wpdb->prepare(
196 "UPDATE {$wpdb->prefix}{$table} SET `{$column}` = %d WHERE `{$column}` = %d",
197 $this->to,
198 $this->from
199 )
200 );
201 // phpcs:enable
202
203 if ( false === $result ) {
204 throw new \RuntimeException(
205 /* translators: %s: database table name. */
206 sprintf( __( 'Failed to move records for table %s.', 'storeengine' ), $table )
207 );
208 }
209
210 $moved[ $table ] = (int) $result;
211 }
212
213 $result = [
214 'from' => $this->from,
215 'to' => $this->to,
216 'moved' => $moved,
217 'subscription_ids' => $subscription_ids,
218 'options' => $options,
219 ];
220
221 /**
222 * Fires while an account move is in progress, inside the transaction.
223 *
224 * Pro addons (license-management, returns, subscription re-auth flagging, etc.)
225 * should move their own records here. Throwing rolls back the entire move.
226 *
227 * @param int $from Source user ID.
228 * @param int $to Target user ID.
229 * @param array $result Running result summary (passed by reference).
230 */
231 do_action_ref_array( 'storeengine/account/moving', [ $this->from, $this->to, &$result ] );
232
233 // Recount derived customer stats for both users.
234 $this->recount_customer_stats( $this->from );
235 $this->recount_customer_stats( $this->to );
236
237 $transaction->commit();
238 } catch ( \Throwable $e ) {
239 $transaction->rollback();
240
241 return new WP_Error( 'storeengine_account_move_failed', $e->getMessage(), [ 'status' => 500 ] );
242 }
243
244 $this->log_move( $result );
245
246 /**
247 * Fires after an account move has been committed.
248 *
249 * @param int $from Source user ID.
250 * @param int $to Target user ID.
251 * @param array $result Final result summary.
252 */
253 do_action( 'storeengine/account/moved', $this->from, $this->to, $result );
254
255 return $result;
256 }
257
258 /**
259 * Recompute total_orders / total_spent user meta from the orders table.
260 *
261 * @param int $user_id User whose stats to recount.
262 */
263 protected function recount_customer_stats( int $user_id ) {
264 global $wpdb;
265
266 $paid_statuses = Helper::get_order_paid_statuses();
267 $placeholders = implode( ', ', array_fill( 0, count( $paid_statuses ), '%s' ) );
268
269 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
270 $stats = $wpdb->get_row(
271 $wpdb->prepare(
272 "SELECT COUNT(*) AS total_orders, COALESCE(SUM(total_amount), 0) AS total_spent
273 FROM {$wpdb->prefix}storeengine_orders
274 WHERE customer_id = %d AND type != 'subscription' AND status IN ( {$placeholders} )",
275 array_merge( [ $user_id ], $paid_statuses )
276 )
277 );
278 // phpcs:enable
279
280 update_user_meta( $user_id, 'storeengine_total_orders', (int) ( $stats->total_orders ?? 0 ) );
281 update_user_meta( $user_id, 'storeengine_total_spent', (float) ( $stats->total_spent ?? 0 ) );
282 }
283
284 /**
285 * Append an audit entry to both users' move logs.
286 *
287 * @param array $result Result summary.
288 */
289 protected function log_move( array $result ) {
290 $entry = [
291 'from' => $this->from,
292 'to' => $this->to,
293 'moved' => $result['moved'] ?? [],
294 'by' => get_current_user_id(),
295 'created_at' => current_time( 'mysql', true ),
296 ];
297
298 foreach ( [ $this->from, $this->to ] as $user_id ) {
299 $log = get_user_meta( $user_id, 'storeengine_account_move_log', true );
300 $log = is_array( $log ) ? $log : [];
301 $log[] = $entry;
302 update_user_meta( $user_id, 'storeengine_account_move_log', $log );
303 }
304 }
305 }
306