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 / api / me.php

me.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/api/me.php

921 lines 36.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Current-user-scoped REST controller for the headless customer dashboard.
4 *
5 * All routes resolve the user via get_current_user_id() so they work with
6 * any WP auth mechanism (JWT, cookies, application passwords).
7 *
8 * @package StoreEngine\API
9 */
10
11 namespace StoreEngine\API;
12
13 use StoreEngine\Classes\DownloadPermissionRepository;
14 use StoreEngine\Classes\Exceptions\StoreEngineException;
15 use StoreEngine\Classes\Order as StoreEngineOrder;
16 use StoreEngine\Classes\OrderCollection;
17 use StoreEngine\Classes\UrlPresigner;
18 use StoreEngine\Utils\Helper;
19 use WP_Error;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_REST_Server;
23 use WP_User;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit;
27 }
28
29 class Me extends AbstractRestApiController {
30
31 protected $rest_base = 'me';
32
33 public static function init() {
34 $self = new self();
35 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
36
37 // `customer_download` is our dl_type. UrlPresigner has already verified
38 // the signature + expiry by the time this fires; here we validate the
39 // permission row (ownership, remaining, expiry), decrement the counter,
40 // and return the file path. DownloadHandler streams it from there.
41 add_filter( 'storeengine/secure_downloads/customer_download/file_data', [ __CLASS__, 'resolve_customer_download_file' ], 10, 3 );
42 }
43
44 public function register_routes() {
45 // /me — profile + overview, update profile.
46 register_rest_route( $this->namespace, '/' . $this->rest_base, [
47 [
48 'methods' => WP_REST_Server::READABLE,
49 'callback' => [ $this, 'get_me' ],
50 'permission_callback' => [ $this, 'permission_check' ],
51 ],
52 [
53 'methods' => WP_REST_Server::EDITABLE,
54 'callback' => [ $this, 'update_me' ],
55 'permission_callback' => [ $this, 'permission_check' ],
56 ],
57 ] );
58
59 // /me/password
60 register_rest_route( $this->namespace, '/' . $this->rest_base . '/password', [
61 'methods' => WP_REST_Server::EDITABLE,
62 'callback' => [ $this, 'change_password' ],
63 'permission_callback' => [ $this, 'permission_check' ],
64 'args' => [
65 'current_password' => [ 'type' => 'string', 'required' => true ],
66 'new_password' => [ 'type' => 'string', 'required' => true ],
67 ],
68 ] );
69
70 // /me/menu — dashboard sidebar (extensible via filter).
71 register_rest_route( $this->namespace, '/' . $this->rest_base . '/menu', [
72 'methods' => WP_REST_Server::READABLE,
73 'callback' => [ $this, 'get_menu' ],
74 'permission_callback' => [ $this, 'permission_check' ],
75 ] );
76
77 // /me/orders
78 register_rest_route( $this->namespace, '/' . $this->rest_base . '/orders', [
79 'methods' => WP_REST_Server::READABLE,
80 'callback' => [ $this, 'list_orders' ],
81 'permission_callback' => [ $this, 'permission_check' ],
82 'args' => [
83 'page' => [ 'type' => 'integer', 'default' => 1 ],
84 'per_page' => [ 'type' => 'integer', 'default' => 10 ],
85 'status' => [ 'type' => 'string' ],
86 ],
87 ] );
88
89 register_rest_route( $this->namespace, '/' . $this->rest_base . '/orders/(?P<id>[\d]+)', [
90 'args' => [
91 'id' => [ 'type' => 'integer' ],
92 ],
93 'methods' => WP_REST_Server::READABLE,
94 'callback' => [ $this, 'get_order' ],
95 'permission_callback' => [ $this, 'permission_check' ],
96 ] );
97
98 register_rest_route( $this->namespace, '/' . $this->rest_base . '/orders/(?P<id>[\d]+)/cancel', [
99 'args' => [
100 'id' => [ 'type' => 'integer' ],
101 ],
102 'methods' => WP_REST_Server::CREATABLE,
103 'callback' => [ $this, 'cancel_order' ],
104 'permission_callback' => [ $this, 'permission_check' ],
105 ] );
106
107 register_rest_route( $this->namespace, '/' . $this->rest_base . '/orders/(?P<id>[\d]+)/pay', [
108 'args' => [
109 'id' => [ 'type' => 'integer' ],
110 ],
111 'methods' => WP_REST_Server::CREATABLE,
112 'callback' => [ $this, 'pay_order' ],
113 'permission_callback' => [ $this, 'permission_check' ],
114 ] );
115
116 register_rest_route( $this->namespace, '/' . $this->rest_base . '/orders/(?P<id>[\d]+)/invoice', [
117 'args' => [
118 'id' => [ 'type' => 'integer' ],
119 ],
120 'methods' => WP_REST_Server::READABLE,
121 'callback' => [ $this, 'get_invoice' ],
122 'permission_callback' => [ $this, 'permission_check' ],
123 ] );
124
125 // /me/downloads
126 register_rest_route( $this->namespace, '/' . $this->rest_base . '/downloads', [
127 'methods' => WP_REST_Server::READABLE,
128 'callback' => [ $this, 'list_downloads' ],
129 'permission_callback' => [ $this, 'permission_check' ],
130 'args' => [
131 'page' => [ 'type' => 'integer', 'default' => 1 ],
132 'per_page' => [ 'type' => 'integer', 'default' => 20 ],
133 ],
134 ] );
135
136 // /me/downloads/{permission_id}/sign — returns a short-lived signed URL.
137 register_rest_route( $this->namespace, '/' . $this->rest_base . '/downloads/(?P<permission_id>[\d]+)/sign', [
138 'args' => [
139 'permission_id' => [ 'type' => 'integer' ],
140 'expires_in' => [ 'type' => 'integer', 'default' => 300 ],
141 ],
142 'methods' => WP_REST_Server::CREATABLE,
143 'callback' => [ $this, 'sign_download' ],
144 'permission_callback' => [ $this, 'permission_check' ],
145 ] );
146
147 // /me/addresses
148 register_rest_route( $this->namespace, '/' . $this->rest_base . '/addresses', [
149 'methods' => WP_REST_Server::READABLE,
150 'callback' => [ $this, 'get_addresses' ],
151 'permission_callback' => [ $this, 'permission_check' ],
152 ] );
153
154 register_rest_route( $this->namespace, '/' . $this->rest_base . '/addresses/(?P<type>billing|shipping)', [
155 'args' => [
156 'type' => [ 'type' => 'string' ],
157 ],
158 'methods' => WP_REST_Server::EDITABLE,
159 'callback' => [ $this, 'update_address' ],
160 'permission_callback' => [ $this, 'permission_check' ],
161 ] );
162
163 // /me/payment-methods
164 register_rest_route( $this->namespace, '/' . $this->rest_base . '/payment-methods', [
165 'methods' => WP_REST_Server::READABLE,
166 'callback' => [ $this, 'list_payment_methods' ],
167 'permission_callback' => [ $this, 'permission_check' ],
168 ] );
169
170 register_rest_route( $this->namespace, '/' . $this->rest_base . '/payment-methods/(?P<id>[\d]+)', [
171 'args' => [
172 'id' => [ 'type' => 'integer' ],
173 ],
174 'methods' => WP_REST_Server::DELETABLE,
175 'callback' => [ $this, 'delete_payment_method' ],
176 'permission_callback' => [ $this, 'permission_check' ],
177 ] );
178
179 register_rest_route( $this->namespace, '/' . $this->rest_base . '/payment-methods/(?P<id>[\d]+)/default', [
180 'args' => [
181 'id' => [ 'type' => 'integer' ],
182 ],
183 'methods' => WP_REST_Server::CREATABLE,
184 'callback' => [ $this, 'set_default_payment_method' ],
185 'permission_callback' => [ $this, 'permission_check' ],
186 ] );
187
188 // /me/notifications
189 register_rest_route( $this->namespace, '/' . $this->rest_base . '/notifications', [
190 [
191 'methods' => WP_REST_Server::READABLE,
192 'callback' => [ $this, 'get_notifications' ],
193 'permission_callback' => [ $this, 'permission_check' ],
194 ],
195 [
196 'methods' => WP_REST_Server::EDITABLE,
197 'callback' => [ $this, 'update_notifications' ],
198 'permission_callback' => [ $this, 'permission_check' ],
199 ],
200 ] );
201
202 // /me/privacy
203 register_rest_route( $this->namespace, '/' . $this->rest_base . '/privacy', [
204 [
205 'methods' => WP_REST_Server::READABLE,
206 'callback' => [ $this, 'get_privacy' ],
207 'permission_callback' => [ $this, 'permission_check' ],
208 ],
209 [
210 'methods' => WP_REST_Server::EDITABLE,
211 'callback' => [ $this, 'update_privacy' ],
212 'permission_callback' => [ $this, 'permission_check' ],
213 ],
214 ] );
215
216 register_rest_route( $this->namespace, '/' . $this->rest_base . '/privacy/erase-request', [
217 'methods' => WP_REST_Server::CREATABLE,
218 'callback' => [ $this, 'request_personal_data_erasure' ],
219 'permission_callback' => [ $this, 'permission_check' ],
220 ] );
221 }
222
223 /**
224 * Single permission gate: must be logged in. Per-resource ownership is
225 * enforced inside each handler.
226 */
227 public function permission_check() {
228 if ( ! is_user_logged_in() ) {
229 return new WP_Error( 'storeengine_rest_not_logged_in', __( 'You must be logged in.', 'storeengine' ), [ 'status' => 401 ] );
230 }
231
232 return true;
233 }
234
235 // -------------------------------------------------------------------
236 // Profile
237 // -------------------------------------------------------------------
238
239 public function get_me( WP_REST_Request $request ) {
240 $user_id = get_current_user_id();
241 $customer = Helper::get_customer( $user_id );
242
243 if ( ! $customer || ! $customer->get_id() ) {
244 return new WP_Error( 'storeengine_rest_customer_not_found', __( 'Customer not found.', 'storeengine' ), [ 'status' => 404 ] );
245 }
246
247 $total_spent = (float) OrderCollection::get_total_spent( $user_id );
248 $repo = ( new DownloadPermissionRepository() )->with_pagination( 1, 1 );
249
250 return rest_ensure_response( [
251 'id' => $customer->get_id(),
252 'username' => $customer->get_username(),
253 'email' => $customer->get_email(),
254 'email_hash' => md5( $customer->get_email() ),
255 'first_name' => $customer->get_first_name(),
256 'last_name' => $customer->get_last_name(),
257 'display_name' => $customer->get_name(),
258 'avatar_url' => get_avatar_url( $user_id ),
259 'user_registered' => $customer->get_user_registered() ? $customer->get_user_registered()->format( 'Y-m-d H:i:s' ) : null,
260 'subscribe_to_email' => $customer->has_subscribe_to_email(),
261 'stats' => [
262 'total_orders' => (int) $customer->get_total_orders(),
263 'total_spent' => $total_spent,
264 'total_downloads' => (int) $repo->total_count_by_customer_id( $user_id ),
265 ],
266 'has_billing_address' => (bool) $customer->get_billing_address_1(),
267 'has_shipping_address' => (bool) $customer->get_shipping_address_1(),
268 ] );
269 }
270
271 public function update_me( WP_REST_Request $request ) {
272 $user_id = get_current_user_id();
273 $body = (array) $request->get_json_params();
274
275 $updates = [ 'ID' => $user_id ];
276
277 if ( isset( $body['first_name'] ) ) {
278 $updates['first_name'] = sanitize_text_field( $body['first_name'] );
279 }
280 if ( isset( $body['last_name'] ) ) {
281 $updates['last_name'] = sanitize_text_field( $body['last_name'] );
282 }
283 if ( isset( $body['display_name'] ) ) {
284 $updates['display_name'] = sanitize_text_field( $body['display_name'] );
285 }
286 if ( isset( $body['email'] ) ) {
287 $email = sanitize_email( $body['email'] );
288 if ( ! is_email( $email ) ) {
289 return new WP_Error( 'storeengine_rest_invalid_email', __( 'Invalid email address.', 'storeengine' ), [ 'status' => 400 ] );
290 }
291 $existing = email_exists( $email );
292 if ( $existing && (int) $existing !== $user_id ) {
293 return new WP_Error( 'storeengine_rest_email_taken', __( 'Email address already in use.', 'storeengine' ), [ 'status' => 409 ] );
294 }
295 $updates['user_email'] = $email;
296 }
297
298 $result = wp_update_user( $updates );
299 if ( is_wp_error( $result ) ) {
300 return $result;
301 }
302
303 if ( array_key_exists( 'subscribe_to_email', $body ) ) {
304 $customer = Helper::get_customer( $user_id );
305 $customer->set_subscribe_to_email( (bool) $body['subscribe_to_email'] );
306 $customer->save();
307 }
308
309 return $this->get_me( $request );
310 }
311
312 public function change_password( WP_REST_Request $request ) {
313 $user_id = get_current_user_id();
314 $current = (string) $request->get_param( 'current_password' );
315 $next = (string) $request->get_param( 'new_password' );
316
317 if ( strlen( $next ) < 8 ) {
318 return new WP_Error( 'storeengine_rest_weak_password', __( 'Password must be at least 8 characters.', 'storeengine' ), [ 'status' => 400 ] );
319 }
320
321 $user = get_userdata( $user_id );
322 if ( ! $user instanceof WP_User || ! wp_check_password( $current, $user->user_pass, $user_id ) ) {
323 return new WP_Error( 'storeengine_rest_bad_password', __( 'Current password is incorrect.', 'storeengine' ), [ 'status' => 400 ] );
324 }
325
326 wp_set_password( $next, $user_id );
327
328 return rest_ensure_response( [ 'updated' => true ] );
329 }
330
331 // -------------------------------------------------------------------
332 // Menu (extensible)
333 // -------------------------------------------------------------------
334
335 public function get_menu( WP_REST_Request $request ) {
336 $default = [
337 [ 'slug' => 'dashboard', 'route' => '', 'label' => __( 'Dashboard', 'storeengine' ), 'icon' => 'layout', 'order' => 0 ],
338 [ 'slug' => 'orders', 'route' => 'orders', 'label' => __( 'Orders', 'storeengine' ), 'icon' => 'box', 'order' => 10 ],
339 [ 'slug' => 'downloads', 'route' => 'downloads', 'label' => __( 'Downloads', 'storeengine' ), 'icon' => 'download','order' => 20 ],
340 [ 'slug' => 'addresses', 'route' => 'addresses', 'label' => __( 'Addresses', 'storeengine' ), 'icon' => 'home', 'order' => 30 ],
341 [ 'slug' => 'payment-methods', 'route' => 'payment-methods', 'label' => __( 'Payment methods', 'storeengine' ), 'icon' => 'card', 'order' => 40 ],
342 [ 'slug' => 'profile', 'route' => 'profile', 'label' => __( 'Account', 'storeengine' ), 'icon' => 'user', 'order' => 50 ],
343 ];
344
345 // Drop payment-methods if no gateway supports tokenization.
346 $supports_tokens = false;
347 foreach ( Helper::get_payment_gateways()->get_available_payment_gateways() as $gateway ) {
348 if ( $gateway->supports( 'add_payment_method' ) || $gateway->supports( 'tokenization' ) ) {
349 $supports_tokens = true;
350 break;
351 }
352 }
353 if ( ! $supports_tokens ) {
354 $default = array_values( array_filter( $default, fn( $i ) => 'payment-methods' !== $i['slug'] ) );
355 }
356
357 /**
358 * Filter the headless dashboard menu. Pro addons (licenses, returns,
359 * subscriptions, etc.) inject their own items here.
360 *
361 * @param array $items Menu items.
362 * @param int $user_id Current user id.
363 */
364 $items = apply_filters( 'storeengine/rest/me/menu_items', $default, get_current_user_id() );
365
366 usort( $items, fn( $a, $b ) => ( $a['order'] ?? 0 ) <=> ( $b['order'] ?? 0 ) );
367
368 return rest_ensure_response( $items );
369 }
370
371 // -------------------------------------------------------------------
372 // Orders
373 // -------------------------------------------------------------------
374
375 public function list_orders( WP_REST_Request $request ) {
376 $user_id = get_current_user_id();
377 $page = max( 1, (int) $request->get_param( 'page' ) );
378 $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
379 $status = $request->get_param( 'status' );
380
381 $where = [
382 'relation' => 'AND',
383 [ 'key' => 'type', 'value' => 'order' ],
384 [ 'key' => 'customer_id', 'value' => $user_id, 'type' => 'NUMERIC' ],
385 ];
386
387 if ( $status && ! in_array( $status, [ 'all', 'any', 'draft' ], true ) ) {
388 $where[] = [ 'key' => 'status', 'value' => $status ];
389 } else {
390 $where[] = [ 'key' => 'status', 'value' => 'draft', 'compare' => '!=' ];
391 }
392
393 $query = new OrderCollection( [
394 'per_page' => $per_page,
395 'page' => $page,
396 'where' => $where,
397 ] );
398
399 $data = [];
400 foreach ( $query->get_results() as $order ) {
401 $data[] = $this->format_order_summary( $order );
402 }
403
404 return $this->prepare_query_response( $data, $query, $request );
405 }
406
407 public function get_order( WP_REST_Request $request ) {
408 $order = $this->load_owned_order( (int) $request->get_param( 'id' ) );
409 if ( is_wp_error( $order ) ) {
410 return $order;
411 }
412
413 $response = rest_ensure_response( $this->format_order_full( $order ) );
414
415 /**
416 * Allow Pro addons (returns, installment plans) to inject extra fields
417 * or actions into the order response. Mirrors the WP-side
418 * `storeengine/dashboard/order/actions` filter.
419 */
420 do_action( 'storeengine/rest/me/order_response', $response, $order, $request );
421
422 return $response;
423 }
424
425 public function cancel_order( WP_REST_Request $request ) {
426 $order = $this->load_owned_order( (int) $request->get_param( 'id' ) );
427 if ( is_wp_error( $order ) ) {
428 return $order;
429 }
430
431 $cancellable = apply_filters( 'storeengine/rest/me/cancellable_statuses', [ 'pending_payment', 'on_hold' ], $order );
432 if ( ! in_array( $order->get_status(), $cancellable, true ) ) {
433 return new WP_Error( 'storeengine_rest_not_cancellable', __( 'This order can no longer be cancelled.', 'storeengine' ), [ 'status' => 409 ] );
434 }
435
436 try {
437 $order->update_status( 'cancelled', __( 'Cancelled by customer from headless dashboard.', 'storeengine' ) );
438 } catch ( StoreEngineException $e ) {
439 return new WP_Error( 'storeengine_rest_cancel_failed', $e->getMessage(), [ 'status' => 500 ] );
440 }
441
442 return rest_ensure_response( $this->format_order_full( $order ) );
443 }
444
445 public function pay_order( WP_REST_Request $request ) {
446 $order = $this->load_owned_order( (int) $request->get_param( 'id' ) );
447 if ( is_wp_error( $order ) ) {
448 return $order;
449 }
450
451 if ( ! $order->needs_payment() ) {
452 return new WP_Error( 'storeengine_rest_not_payable', __( 'This order is already paid.', 'storeengine' ), [ 'status' => 409 ] );
453 }
454
455 // Return the order's pay URL — the Checkout API at /checkout/pay-order
456 // handles the actual payment flow; the storefront redirects there.
457 $pay_url = $order->get_checkout_payment_url();
458
459 return rest_ensure_response( [
460 'order_id' => $order->get_id(),
461 'pay_url' => $pay_url,
462 'amount' => (float) $order->get_total_amount(),
463 'currency' => $order->get_currency(),
464 ] );
465 }
466
467 public function get_invoice( WP_REST_Request $request ) {
468 $order = $this->load_owned_order( (int) $request->get_param( 'id' ) );
469 if ( is_wp_error( $order ) ) {
470 return $order;
471 }
472
473 // Default invoice URL = order's view URL. Invoice addons (if any) can
474 // override via filter to return a PDF URL or signed link.
475 $invoice_url = apply_filters( 'storeengine/rest/me/invoice_url', $order->get_view_order_url(), $order );
476
477 return rest_ensure_response( [
478 'order_id' => $order->get_id(),
479 'invoice_url' => $invoice_url,
480 ] );
481 }
482
483 /**
484 * @return StoreEngineOrder|WP_Error
485 */
486 protected function load_owned_order( int $id ) {
487 if ( $id <= 0 ) {
488 return new WP_Error( 'storeengine_rest_invalid_id', __( 'Invalid order id.', 'storeengine' ), [ 'status' => 400 ] );
489 }
490
491 $order = Helper::get_order( $id );
492 if ( is_wp_error( $order ) ) {
493 return $order;
494 }
495 if ( ! $order ) {
496 return new WP_Error( 'storeengine_rest_order_not_found', __( 'Order not found.', 'storeengine' ), [ 'status' => 404 ] );
497 }
498 if ( (int) $order->get_customer_id() !== get_current_user_id() ) {
499 // Return 404 (not 403) to avoid leaking existence.
500 return new WP_Error( 'storeengine_rest_order_not_found', __( 'Order not found.', 'storeengine' ), [ 'status' => 404 ] );
501 }
502
503 return $order;
504 }
505
506 protected function format_order_summary( StoreEngineOrder $order ): array {
507 return [
508 'id' => $order->get_id(),
509 'number' => $order->get_id(),
510 'status' => $order->get_status(),
511 'paid_status' => $order->get_paid_status(),
512 'currency' => $order->get_currency(),
513 'total' => (float) $order->get_total_amount(),
514 'item_count' => count( $order->get_items() ),
515 'date_created_gmt' => $this->date_as_string( $order->get_date_created_gmt() ),
516 'date_paid_gmt' => $this->date_as_string( $order->get_date_paid_gmt() ),
517 'needs_payment' => $order->needs_payment(),
518 ];
519 }
520
521 protected function format_order_full( StoreEngineOrder $order ): array {
522 $items = [];
523 foreach ( $order->get_items() as $item ) {
524 $items[] = [
525 'id' => $item->get_id(),
526 'name' => $item->get_name(),
527 'product_id' => method_exists( $item, 'get_product_id' ) ? $item->get_product_id() : null,
528 'quantity' => method_exists( $item, 'get_quantity' ) ? $item->get_quantity() : null,
529 'subtotal' => method_exists( $item, 'get_subtotal' ) ? (float) $item->get_subtotal() : null,
530 'total' => method_exists( $item, 'get_total' ) ? (float) $item->get_total() : null,
531 'type' => $item->get_type(),
532 ];
533 }
534
535 $billing = $order->get_address( 'billing' );
536 $shipping = $order->get_address( 'shipping' );
537 unset( $billing['address_type'], $shipping['address_type'] );
538
539 return array_merge(
540 $this->format_order_summary( $order ),
541 [
542 'subtotal' => (float) $order->get_subtotal(),
543 'tax_total' => (float) $order->get_tax_amount(),
544 'shipping_total' => (float) $order->get_shipping_total(),
545 'discount_total' => (float) $order->get_total_discount(),
546 'refunded_total' => (float) $order->get_total_refunded(),
547 'payment_method' => $order->get_payment_method(),
548 'payment_method_title' => $order->get_payment_method_title(),
549 'transaction_id' => $order->get_transaction_id(),
550 'customer_note' => $order->get_customer_note(),
551 'billing_address' => $billing,
552 'shipping_address' => $shipping,
553 'items' => $items,
554 'downloads' => array_values( $order->get_downloadable_items() ),
555 ]
556 );
557 }
558
559 // -------------------------------------------------------------------
560 // Downloads
561 // -------------------------------------------------------------------
562
563 public function list_downloads( WP_REST_Request $request ) {
564 global $wpdb;
565
566 $user_id = get_current_user_id();
567 $page = max( 1, (int) $request->get_param( 'page' ) );
568 $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
569 $offset = ( $page - 1 ) * $per_page;
570
571 $table = $wpdb->prefix . 'storeengine_downloadable_product_permissions';
572 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared (%d) query on a custom StoreEngine permissions table; $table is $wpdb->prefix + a literal; not cacheable.
573 $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE user_id = %d", $user_id ) );
574 $rows = $wpdb->get_results( $wpdb->prepare(
575 "SELECT id, order_id, product_id, download_id, downloads_remaining, download_count, access_granted, access_expires
576 FROM $table
577 WHERE user_id = %d
578 ORDER BY id DESC
579 LIMIT %d OFFSET %d",
580 $user_id, $per_page, $offset
581 ) );
582 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching
583
584 $data = [];
585 foreach ( (array) $rows as $row ) {
586 $file_name = $this->resolve_download_filename( (int) $row->product_id, (string) $row->download_id );
587
588 $data[] = [
589 'permission_id' => (int) $row->id,
590 'order_id' => (int) $row->order_id,
591 'product_id' => (int) $row->product_id,
592 'product_name' => get_the_title( (int) $row->product_id ),
593 'download_id' => (string) $row->download_id,
594 'download_name' => $file_name,
595 'downloads_remaining' => is_null( $row->downloads_remaining ) ? null : (int) $row->downloads_remaining,
596 'download_count' => (int) $row->download_count,
597 'access_granted' => $row->access_granted ?: null,
598 'access_expires' => $row->access_expires ?: null,
599 'is_expired' => $row->access_expires && strtotime( $row->access_expires ) < time(),
600 ];
601 }
602
603 $response = rest_ensure_response( $data );
604 $response->header( 'X-WP-Total', $total );
605 $response->header( 'X-WP-TotalPages', max( 1, (int) ceil( $total / $per_page ) ) );
606
607 return $response;
608 }
609
610 protected function resolve_download_filename( int $product_id, string $download_id ): string {
611 $files = get_post_meta( $product_id, '_storeengine_product_downloadable_files', true );
612 if ( empty( $files ) ) {
613 return '';
614 }
615 $files = maybe_unserialize( $files );
616 if ( ! is_array( $files ) ) {
617 return '';
618 }
619 foreach ( $files as $file ) {
620 if ( ( $file['id'] ?? null ) === $download_id ) {
621 return (string) ( $file['name'] ?? '' );
622 }
623 }
624
625 return '';
626 }
627
628 public function sign_download( WP_REST_Request $request ) {
629 global $wpdb;
630
631 $user_id = get_current_user_id();
632 $permission_id = (int) $request->get_param( 'permission_id' );
633 $expires_in = (int) ( $request->get_param( 'expires_in' ) ?: 300 );
634 $expires_in = max( 30, min( 3600, $expires_in ) );
635
636 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared (%d) single-row read on a custom StoreEngine permissions table; not cacheable.
637 $row = $wpdb->get_row( $wpdb->prepare(
638 "SELECT id, user_id, order_id, product_id, download_id, downloads_remaining, access_expires
639 FROM {$wpdb->prefix}storeengine_downloadable_product_permissions
640 WHERE id = %d",
641 $permission_id
642 ) );
643
644 if ( ! $row ) {
645 return new WP_Error( 'storeengine_rest_download_not_found', __( 'Download not found.', 'storeengine' ), [ 'status' => 404 ] );
646 }
647 if ( (int) $row->user_id !== $user_id ) {
648 return new WP_Error( 'storeengine_rest_download_not_found', __( 'Download not found.', 'storeengine' ), [ 'status' => 404 ] );
649 }
650 if ( ! is_null( $row->downloads_remaining ) && (int) $row->downloads_remaining <= 0 ) {
651 return new WP_Error( 'storeengine_rest_download_exhausted', __( 'No remaining downloads for this file.', 'storeengine' ), [ 'status' => 410 ] );
652 }
653 if ( $row->access_expires && strtotime( $row->access_expires ) < time() ) {
654 return new WP_Error( 'storeengine_rest_download_expired', __( 'Download access has expired.', 'storeengine' ), [ 'status' => 410 ] );
655 }
656
657 $args = [
658 'se_secure_dl' => (int) $row->product_id,
659 'resource' => (int) $row->id,
660 'type' => 'customer_download',
661 ];
662
663 try {
664 $url = UrlPresigner::init()->signUrl( add_query_arg( $args, home_url( '/' ) ), $expires_in );
665 } catch ( StoreEngineException $e ) {
666 return new WP_Error( 'storeengine_rest_sign_failed', $e->getMessage(), [ 'status' => 500 ] );
667 }
668
669 return rest_ensure_response( [
670 'url' => $url,
671 'expires_in' => $expires_in,
672 'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + $expires_in ),
673 'file_name' => $this->resolve_download_filename( (int) $row->product_id, (string) $row->download_id ),
674 ] );
675 }
676
677 /**
678 * Hooked at `storeengine/secure_downloads/customer_download/file_data`.
679 *
680 * Called from DownloadHandler::handle_secure_download() AFTER the URL
681 * signature has been verified. Validates the permission row one more time,
682 * atomically decrements remaining, increments count, and returns the
683 * file path/name for DownloadHandler::download() to stream.
684 *
685 * @param array $file_data Default empty.
686 * @param mixed $resource Permission id from the signed URL's `resource` arg.
687 */
688 public static function resolve_customer_download_file( $file_data, $resource, $product ) {
689 global $wpdb;
690
691 $permission_id = absint( $resource );
692 if ( ! $permission_id ) {
693 return $file_data;
694 }
695
696 $table = $wpdb->prefix . 'storeengine_downloadable_product_permissions';
697 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared (%d) query on a custom StoreEngine permissions table; $table is $wpdb->prefix + a literal; not cacheable.
698 $row = $wpdb->get_row( $wpdb->prepare(
699 "SELECT id, user_id, order_id, product_id, download_id, downloads_remaining, access_expires FROM $table WHERE id = %d",
700 $permission_id
701 ) );
702 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching
703
704 if ( ! $row ) {
705 return new WP_Error( 'invalid_download', __( 'Download not found.', 'storeengine' ), 404 );
706 }
707 if ( $product && (int) $product->get_id() !== (int) $row->product_id ) {
708 return new WP_Error( 'invalid_download', __( 'Product mismatch.', 'storeengine' ), 403 );
709 }
710 if ( $row->access_expires && strtotime( $row->access_expires ) < time() ) {
711 return new WP_Error( 'invalid_download', __( 'Download access has expired.', 'storeengine' ), 410 );
712 }
713 if ( ! is_null( $row->downloads_remaining ) && (int) $row->downloads_remaining <= 0 ) {
714 return new WP_Error( 'invalid_download', __( 'No remaining downloads.', 'storeengine' ), 410 );
715 }
716
717 // Resolve the file from product downloadables.
718 $files = get_post_meta( (int) $row->product_id, '_storeengine_product_downloadable_files', true );
719 $files = is_array( $files ) ? $files : (array) maybe_unserialize( $files );
720 $file = null;
721 foreach ( $files as $candidate ) {
722 if ( ( $candidate['id'] ?? null ) === $row->download_id ) {
723 $file = $candidate;
724 break;
725 }
726 }
727 if ( ! $file || empty( $file['file'] ) ) {
728 return new WP_Error( 'invalid_download', __( 'No file defined.', 'storeengine' ), 404 );
729 }
730
731 // Atomic decrement — only fires on initial 200 responses; range
732 // requests within the same signed-URL window won't hit this twice
733 // because they're served by the streaming code, not re-entered here.
734 if ( ! is_null( $row->downloads_remaining ) ) {
735 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared (%d) query on a custom StoreEngine permissions table; $table is $wpdb->prefix + a literal; not cacheable.
736 $wpdb->query( $wpdb->prepare(
737 "UPDATE $table
738 SET downloads_remaining = downloads_remaining - 1, download_count = download_count + 1
739 WHERE id = %d AND downloads_remaining > 0",
740 $permission_id
741 ) );
742 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching
743 } else {
744 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared (%d) query on a custom StoreEngine permissions table; $table is $wpdb->prefix + a literal; not cacheable.
745 $wpdb->query( $wpdb->prepare(
746 "UPDATE $table SET download_count = download_count + 1 WHERE id = %d",
747 $permission_id
748 ) );
749 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.NoCaching
750 }
751
752 return [
753 'file_path' => (string) $file['file'],
754 'file_name' => (string) ( $file['name'] ?? basename( $file['file'] ) ),
755 ];
756 }
757
758 // -------------------------------------------------------------------
759 // Addresses
760 // -------------------------------------------------------------------
761
762 public function get_addresses( WP_REST_Request $request ) {
763 $user_id = get_current_user_id();
764 $customer = Helper::get_customer( $user_id );
765
766 return rest_ensure_response( [
767 'billing' => $this->extract_address( $customer, 'billing' ),
768 'shipping' => $this->extract_address( $customer, 'shipping' ),
769 ] );
770 }
771
772 public function update_address( WP_REST_Request $request ) {
773 $user_id = get_current_user_id();
774 $customer = Helper::get_customer( $user_id );
775 $type = $request->get_param( 'type' );
776 $fields = (array) $request->get_json_params();
777
778 $allowed = [
779 'first_name', 'last_name', 'company', 'phone', 'email',
780 'address_1', 'address_2', 'city', 'state', 'country', 'postcode',
781 ];
782
783 foreach ( $allowed as $field ) {
784 if ( array_key_exists( $field, $fields ) ) {
785 $setter = "set_{$type}_{$field}";
786 if ( is_callable( [ $customer, $setter ] ) ) {
787 $customer->{$setter}( sanitize_text_field( (string) $fields[ $field ] ) );
788 }
789 }
790 }
791
792 $customer->save();
793
794 return rest_ensure_response( $this->extract_address( $customer, $type ) );
795 }
796
797 protected function extract_address( $customer, string $type ): array {
798 $fields = [ 'first_name', 'last_name', 'company', 'phone', 'email', 'address_1', 'address_2', 'city', 'state', 'country', 'postcode' ];
799 $address = [];
800 foreach ( $fields as $field ) {
801 $getter = "get_{$type}_{$field}";
802 if ( is_callable( [ $customer, $getter ] ) ) {
803 $address[ $field ] = $customer->{$getter}();
804 }
805 }
806
807 return $address;
808 }
809
810 // -------------------------------------------------------------------
811 // Payment methods
812 // -------------------------------------------------------------------
813
814 public function list_payment_methods( WP_REST_Request $request ) {
815 $user_id = get_current_user_id();
816 $tokens = function_exists( 'storeengine_get_customer_payment_tokens' )
817 ? storeengine_get_customer_payment_tokens( $user_id )
818 : [];
819
820 // Generic shape — concrete fields depend on which gateway issued the
821 // token. Storefront should render whatever fields the gateway returns.
822 $data = [];
823 foreach ( (array) $tokens as $token ) {
824 $data[] = apply_filters( 'storeengine/rest/me/payment_method', [
825 'id' => is_callable( [ $token, 'get_id' ] ) ? $token->get_id() : ( $token->id ?? null ),
826 'gateway' => is_callable( [ $token, 'get_gateway_id' ] ) ? $token->get_gateway_id() : ( $token->gateway ?? null ),
827 'display_name' => is_callable( [ $token, 'get_display_name' ] ) ? $token->get_display_name() : '',
828 'is_default' => is_callable( [ $token, 'is_default' ] ) ? (bool) $token->is_default() : false,
829 ], $token );
830 }
831
832 return rest_ensure_response( $data );
833 }
834
835 public function delete_payment_method( WP_REST_Request $request ) {
836 do_action( 'storeengine/rest/me/delete_payment_method', (int) $request->get_param( 'id' ), get_current_user_id() );
837
838 return rest_ensure_response( [ 'deleted' => true ] );
839 }
840
841 public function set_default_payment_method( WP_REST_Request $request ) {
842 do_action( 'storeengine/rest/me/set_default_payment_method', (int) $request->get_param( 'id' ), get_current_user_id() );
843
844 return rest_ensure_response( [ 'updated' => true ] );
845 }
846
847 // -------------------------------------------------------------------
848 // Notifications + Privacy
849 // -------------------------------------------------------------------
850
851 public function get_notifications( WP_REST_Request $request ) {
852 $user_id = get_current_user_id();
853
854 return rest_ensure_response( [
855 'subscribe_to_email' => (bool) get_user_meta( $user_id, 'storeengine_subscribe_to_email', true ),
856 'order_email' => 'no' !== get_user_meta( $user_id, 'storeengine_notify_order_email', true ),
857 'marketing_email' => (bool) get_user_meta( $user_id, 'storeengine_notify_marketing_email', true ),
858 ] );
859 }
860
861 public function update_notifications( WP_REST_Request $request ) {
862 $user_id = get_current_user_id();
863 $body = (array) $request->get_json_params();
864
865 if ( array_key_exists( 'subscribe_to_email', $body ) ) {
866 update_user_meta( $user_id, 'storeengine_subscribe_to_email', (bool) $body['subscribe_to_email'] ? 1 : 0 );
867 }
868 if ( array_key_exists( 'order_email', $body ) ) {
869 update_user_meta( $user_id, 'storeengine_notify_order_email', $body['order_email'] ? 'yes' : 'no' );
870 }
871 if ( array_key_exists( 'marketing_email', $body ) ) {
872 update_user_meta( $user_id, 'storeengine_notify_marketing_email', (bool) $body['marketing_email'] ? 1 : 0 );
873 }
874
875 return $this->get_notifications( $request );
876 }
877
878 public function get_privacy( WP_REST_Request $request ) {
879 $user_id = get_current_user_id();
880
881 return rest_ensure_response( [
882 'data_sharing_consent' => (bool) get_user_meta( $user_id, 'storeengine_privacy_data_sharing', true ),
883 'profiling_consent' => (bool) get_user_meta( $user_id, 'storeengine_privacy_profiling', true ),
884 ] );
885 }
886
887 public function update_privacy( WP_REST_Request $request ) {
888 $user_id = get_current_user_id();
889 $body = (array) $request->get_json_params();
890
891 if ( array_key_exists( 'data_sharing_consent', $body ) ) {
892 update_user_meta( $user_id, 'storeengine_privacy_data_sharing', (bool) $body['data_sharing_consent'] ? 1 : 0 );
893 }
894 if ( array_key_exists( 'profiling_consent', $body ) ) {
895 update_user_meta( $user_id, 'storeengine_privacy_profiling', (bool) $body['profiling_consent'] ? 1 : 0 );
896 }
897
898 return $this->get_privacy( $request );
899 }
900
901 public function request_personal_data_erasure( WP_REST_Request $request ) {
902 $user_id = get_current_user_id();
903 $user = get_userdata( $user_id );
904 if ( ! $user ) {
905 return new WP_Error( 'storeengine_rest_user_not_found', __( 'User not found.', 'storeengine' ), [ 'status' => 404 ] );
906 }
907
908 $request_id = wp_create_user_request( $user->user_email, 'remove_personal_data' );
909 if ( is_wp_error( $request_id ) ) {
910 return $request_id;
911 }
912
913 wp_send_user_request( $request_id );
914
915 return rest_ensure_response( [
916 'request_id' => $request_id,
917 'status' => 'requested',
918 ] );
919 }
920 }
921