PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.1.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.1.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.1.0, at includes/api/me.php

920 lines 35.3 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
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
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 $row = $wpdb->get_row( $wpdb->prepare(
637 "SELECT id, user_id, order_id, product_id, download_id, downloads_remaining, access_expires
638 FROM {$wpdb->prefix}storeengine_downloadable_product_permissions
639 WHERE id = %d",
640 $permission_id
641 ) );
642
643 if ( ! $row ) {
644 return new WP_Error( 'storeengine_rest_download_not_found', __( 'Download not found.', 'storeengine' ), [ 'status' => 404 ] );
645 }
646 if ( (int) $row->user_id !== $user_id ) {
647 return new WP_Error( 'storeengine_rest_download_not_found', __( 'Download not found.', 'storeengine' ), [ 'status' => 404 ] );
648 }
649 if ( ! is_null( $row->downloads_remaining ) && (int) $row->downloads_remaining <= 0 ) {
650 return new WP_Error( 'storeengine_rest_download_exhausted', __( 'No remaining downloads for this file.', 'storeengine' ), [ 'status' => 410 ] );
651 }
652 if ( $row->access_expires && strtotime( $row->access_expires ) < time() ) {
653 return new WP_Error( 'storeengine_rest_download_expired', __( 'Download access has expired.', 'storeengine' ), [ 'status' => 410 ] );
654 }
655
656 $args = [
657 'se_secure_dl' => (int) $row->product_id,
658 'resource' => (int) $row->id,
659 'type' => 'customer_download',
660 ];
661
662 try {
663 $url = UrlPresigner::init()->signUrl( add_query_arg( $args, home_url( '/' ) ), $expires_in );
664 } catch ( StoreEngineException $e ) {
665 return new WP_Error( 'storeengine_rest_sign_failed', $e->getMessage(), [ 'status' => 500 ] );
666 }
667
668 return rest_ensure_response( [
669 'url' => $url,
670 'expires_in' => $expires_in,
671 'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + $expires_in ),
672 'file_name' => $this->resolve_download_filename( (int) $row->product_id, (string) $row->download_id ),
673 ] );
674 }
675
676 /**
677 * Hooked at `storeengine/secure_downloads/customer_download/file_data`.
678 *
679 * Called from DownloadHandler::handle_secure_download() AFTER the URL
680 * signature has been verified. Validates the permission row one more time,
681 * atomically decrements remaining, increments count, and returns the
682 * file path/name for DownloadHandler::download() to stream.
683 *
684 * @param array $file_data Default empty.
685 * @param mixed $resource Permission id from the signed URL's `resource` arg.
686 */
687 public static function resolve_customer_download_file( $file_data, $resource, $product ) {
688 global $wpdb;
689
690 $permission_id = absint( $resource );
691 if ( ! $permission_id ) {
692 return $file_data;
693 }
694
695 $table = $wpdb->prefix . 'storeengine_downloadable_product_permissions';
696 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
697 $row = $wpdb->get_row( $wpdb->prepare(
698 "SELECT id, user_id, order_id, product_id, download_id, downloads_remaining, access_expires FROM $table WHERE id = %d",
699 $permission_id
700 ) );
701 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
702
703 if ( ! $row ) {
704 return new WP_Error( 'invalid_download', __( 'Download not found.', 'storeengine' ), 404 );
705 }
706 if ( $product && (int) $product->get_id() !== (int) $row->product_id ) {
707 return new WP_Error( 'invalid_download', __( 'Product mismatch.', 'storeengine' ), 403 );
708 }
709 if ( $row->access_expires && strtotime( $row->access_expires ) < time() ) {
710 return new WP_Error( 'invalid_download', __( 'Download access has expired.', 'storeengine' ), 410 );
711 }
712 if ( ! is_null( $row->downloads_remaining ) && (int) $row->downloads_remaining <= 0 ) {
713 return new WP_Error( 'invalid_download', __( 'No remaining downloads.', 'storeengine' ), 410 );
714 }
715
716 // Resolve the file from product downloadables.
717 $files = get_post_meta( (int) $row->product_id, '_storeengine_product_downloadable_files', true );
718 $files = is_array( $files ) ? $files : (array) maybe_unserialize( $files );
719 $file = null;
720 foreach ( $files as $candidate ) {
721 if ( ( $candidate['id'] ?? null ) === $row->download_id ) {
722 $file = $candidate;
723 break;
724 }
725 }
726 if ( ! $file || empty( $file['file'] ) ) {
727 return new WP_Error( 'invalid_download', __( 'No file defined.', 'storeengine' ), 404 );
728 }
729
730 // Atomic decrement — only fires on initial 200 responses; range
731 // requests within the same signed-URL window won't hit this twice
732 // because they're served by the streaming code, not re-entered here.
733 if ( ! is_null( $row->downloads_remaining ) ) {
734 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
735 $wpdb->query( $wpdb->prepare(
736 "UPDATE $table
737 SET downloads_remaining = downloads_remaining - 1, download_count = download_count + 1
738 WHERE id = %d AND downloads_remaining > 0",
739 $permission_id
740 ) );
741 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
742 } else {
743 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
744 $wpdb->query( $wpdb->prepare(
745 "UPDATE $table SET download_count = download_count + 1 WHERE id = %d",
746 $permission_id
747 ) );
748 //phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
749 }
750
751 return [
752 'file_path' => (string) $file['file'],
753 'file_name' => (string) ( $file['name'] ?? basename( $file['file'] ) ),
754 ];
755 }
756
757 // -------------------------------------------------------------------
758 // Addresses
759 // -------------------------------------------------------------------
760
761 public function get_addresses( WP_REST_Request $request ) {
762 $user_id = get_current_user_id();
763 $customer = Helper::get_customer( $user_id );
764
765 return rest_ensure_response( [
766 'billing' => $this->extract_address( $customer, 'billing' ),
767 'shipping' => $this->extract_address( $customer, 'shipping' ),
768 ] );
769 }
770
771 public function update_address( WP_REST_Request $request ) {
772 $user_id = get_current_user_id();
773 $customer = Helper::get_customer( $user_id );
774 $type = $request->get_param( 'type' );
775 $fields = (array) $request->get_json_params();
776
777 $allowed = [
778 'first_name', 'last_name', 'company', 'phone', 'email',
779 'address_1', 'address_2', 'city', 'state', 'country', 'postcode',
780 ];
781
782 foreach ( $allowed as $field ) {
783 if ( array_key_exists( $field, $fields ) ) {
784 $setter = "set_{$type}_{$field}";
785 if ( is_callable( [ $customer, $setter ] ) ) {
786 $customer->{$setter}( sanitize_text_field( (string) $fields[ $field ] ) );
787 }
788 }
789 }
790
791 $customer->save();
792
793 return rest_ensure_response( $this->extract_address( $customer, $type ) );
794 }
795
796 protected function extract_address( $customer, string $type ): array {
797 $fields = [ 'first_name', 'last_name', 'company', 'phone', 'email', 'address_1', 'address_2', 'city', 'state', 'country', 'postcode' ];
798 $address = [];
799 foreach ( $fields as $field ) {
800 $getter = "get_{$type}_{$field}";
801 if ( is_callable( [ $customer, $getter ] ) ) {
802 $address[ $field ] = $customer->{$getter}();
803 }
804 }
805
806 return $address;
807 }
808
809 // -------------------------------------------------------------------
810 // Payment methods
811 // -------------------------------------------------------------------
812
813 public function list_payment_methods( WP_REST_Request $request ) {
814 $user_id = get_current_user_id();
815 $tokens = function_exists( 'storeengine_get_customer_payment_tokens' )
816 ? storeengine_get_customer_payment_tokens( $user_id )
817 : [];
818
819 // Generic shape — concrete fields depend on which gateway issued the
820 // token. Storefront should render whatever fields the gateway returns.
821 $data = [];
822 foreach ( (array) $tokens as $token ) {
823 $data[] = apply_filters( 'storeengine/rest/me/payment_method', [
824 'id' => is_callable( [ $token, 'get_id' ] ) ? $token->get_id() : ( $token->id ?? null ),
825 'gateway' => is_callable( [ $token, 'get_gateway_id' ] ) ? $token->get_gateway_id() : ( $token->gateway ?? null ),
826 'display_name' => is_callable( [ $token, 'get_display_name' ] ) ? $token->get_display_name() : '',
827 'is_default' => is_callable( [ $token, 'is_default' ] ) ? (bool) $token->is_default() : false,
828 ], $token );
829 }
830
831 return rest_ensure_response( $data );
832 }
833
834 public function delete_payment_method( WP_REST_Request $request ) {
835 do_action( 'storeengine/rest/me/delete_payment_method', (int) $request->get_param( 'id' ), get_current_user_id() );
836
837 return rest_ensure_response( [ 'deleted' => true ] );
838 }
839
840 public function set_default_payment_method( WP_REST_Request $request ) {
841 do_action( 'storeengine/rest/me/set_default_payment_method', (int) $request->get_param( 'id' ), get_current_user_id() );
842
843 return rest_ensure_response( [ 'updated' => true ] );
844 }
845
846 // -------------------------------------------------------------------
847 // Notifications + Privacy
848 // -------------------------------------------------------------------
849
850 public function get_notifications( WP_REST_Request $request ) {
851 $user_id = get_current_user_id();
852
853 return rest_ensure_response( [
854 'subscribe_to_email' => (bool) get_user_meta( $user_id, 'storeengine_subscribe_to_email', true ),
855 'order_email' => 'no' !== get_user_meta( $user_id, 'storeengine_notify_order_email', true ),
856 'marketing_email' => (bool) get_user_meta( $user_id, 'storeengine_notify_marketing_email', true ),
857 ] );
858 }
859
860 public function update_notifications( WP_REST_Request $request ) {
861 $user_id = get_current_user_id();
862 $body = (array) $request->get_json_params();
863
864 if ( array_key_exists( 'subscribe_to_email', $body ) ) {
865 update_user_meta( $user_id, 'storeengine_subscribe_to_email', (bool) $body['subscribe_to_email'] ? 1 : 0 );
866 }
867 if ( array_key_exists( 'order_email', $body ) ) {
868 update_user_meta( $user_id, 'storeengine_notify_order_email', $body['order_email'] ? 'yes' : 'no' );
869 }
870 if ( array_key_exists( 'marketing_email', $body ) ) {
871 update_user_meta( $user_id, 'storeengine_notify_marketing_email', (bool) $body['marketing_email'] ? 1 : 0 );
872 }
873
874 return $this->get_notifications( $request );
875 }
876
877 public function get_privacy( WP_REST_Request $request ) {
878 $user_id = get_current_user_id();
879
880 return rest_ensure_response( [
881 'data_sharing_consent' => (bool) get_user_meta( $user_id, 'storeengine_privacy_data_sharing', true ),
882 'profiling_consent' => (bool) get_user_meta( $user_id, 'storeengine_privacy_profiling', true ),
883 ] );
884 }
885
886 public function update_privacy( WP_REST_Request $request ) {
887 $user_id = get_current_user_id();
888 $body = (array) $request->get_json_params();
889
890 if ( array_key_exists( 'data_sharing_consent', $body ) ) {
891 update_user_meta( $user_id, 'storeengine_privacy_data_sharing', (bool) $body['data_sharing_consent'] ? 1 : 0 );
892 }
893 if ( array_key_exists( 'profiling_consent', $body ) ) {
894 update_user_meta( $user_id, 'storeengine_privacy_profiling', (bool) $body['profiling_consent'] ? 1 : 0 );
895 }
896
897 return $this->get_privacy( $request );
898 }
899
900 public function request_personal_data_erasure( WP_REST_Request $request ) {
901 $user_id = get_current_user_id();
902 $user = get_userdata( $user_id );
903 if ( ! $user ) {
904 return new WP_Error( 'storeengine_rest_user_not_found', __( 'User not found.', 'storeengine' ), [ 'status' => 404 ] );
905 }
906
907 $request_id = wp_create_user_request( $user->user_email, 'remove_personal_data' );
908 if ( is_wp_error( $request_id ) ) {
909 return $request_id;
910 }
911
912 wp_send_user_request( $request_id );
913
914 return rest_ensure_response( [
915 'request_id' => $request_id,
916 'status' => 'requested',
917 ] );
918 }
919 }
920