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 / bundle-sync.php

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

236 lines 8.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bundle sync REST controller.
4 *
5 * Retroactively grants every product currently in a bundle to everyone who has
6 * already purchased that bundle. A bundle's contents are snapshotted into the
7 * order line-item `_bundles` meta at checkout, so products added to a bundle
8 * later never reach past buyers. This endpoint walks the paid orders containing
9 * the bundle (in batches), merges the missing items into each order's snapshot,
10 * grants download permissions for them, and fires an action add-ons listen to
11 * (e.g. license management) to backfill their own grants. Every step is
12 * idempotent, so re-running grants nothing new.
13 *
14 * @package StoreEngine\API
15 */
16
17 namespace StoreEngine\API;
18
19 use StoreEngine\Classes\AbstractOrder;
20 use StoreEngine\Hooks\DownloadPermissionHooks;
21 use StoreEngine\Utils\Helper;
22 use WP_Error;
23 use WP_REST_Request;
24 use WP_REST_Server;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 class BundleSync extends AbstractRestApiController {
31
32 protected $rest_base = 'bundle';
33
34 public static function init() {
35 $self = new self();
36 add_action( 'rest_api_init', [ $self, 'register_routes' ] );
37 }
38
39 public function register_routes() {
40 register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/sync-purchasers', [
41 'args' => [
42 'id' => [
43 'description' => __( 'Bundle product ID.', 'storeengine' ),
44 'type' => 'integer',
45 'required' => true,
46 ],
47 ],
48 [
49 'methods' => WP_REST_Server::CREATABLE,
50 'callback' => [ $this, 'sync_purchasers' ],
51 'permission_callback' => [ $this, 'permissions_check' ],
52 'args' => [
53 'page' => [
54 'type' => 'integer',
55 'default' => 1,
56 'minimum' => 1,
57 ],
58 'per_page' => [
59 'type' => 'integer',
60 'default' => 25,
61 'minimum' => 1,
62 'maximum' => 100,
63 ],
64 ],
65 ],
66 ] );
67 }
68
69 public function permissions_check() {
70 return Helper::check_rest_user_cap( 'manage_options' );
71 }
72
73 /**
74 * Process one page of paid orders containing the bundle.
75 *
76 * @param WP_REST_Request $request
77 *
78 * @return \WP_REST_Response|WP_Error
79 */
80 public function sync_purchasers( WP_REST_Request $request ) {
81 global $wpdb;
82
83 $bundle_id = absint( $request->get_param( 'id' ) );
84 $page = max( 1, absint( $request->get_param( 'page' ) ) );
85 $per_page = min( 100, max( 1, absint( $request->get_param( 'per_page' ) ) ) );
86
87 $product = Helper::get_product( $bundle_id );
88 if ( ! $product || 'bundled' !== $product->get_type() ) {
89 return new WP_Error( 'invalid-bundle', __( 'Bundle product not found.', 'storeengine' ), [ 'status' => 404 ] );
90 }
91
92 $bundle_items = $product->get_bundles();
93 if ( empty( $bundle_items ) ) {
94 return new WP_Error( 'empty-bundle', __( 'This bundle has no items to sync.', 'storeengine' ), [ 'status' => 400 ] );
95 }
96
97 $paid_statuses = Helper::get_order_paid_statuses();
98 if ( empty( $paid_statuses ) ) {
99 return new WP_Error( 'no-paid-statuses', __( 'No paid order statuses are configured.', 'storeengine' ), [ 'status' => 400 ] );
100 }
101 $placeholders = implode( ',', array_fill( 0, count( $paid_statuses ), '%s' ) );
102
103 $items_table = $wpdb->prefix . 'storeengine_order_items';
104 $meta_table = $wpdb->prefix . 'storeengine_order_item_meta';
105 $orders_table = $wpdb->prefix . 'storeengine_orders';
106
107 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Trusted $wpdb->prefix table identifiers interpolated; user values bound via prepare() with a dynamic %s IN() list (count is correct at runtime); custom StoreEngine tables.
108 $total = (int) $wpdb->get_var( $wpdb->prepare(
109 "SELECT COUNT( DISTINCT oi.order_id )
110 FROM {$items_table} oi
111 INNER JOIN {$meta_table} oim ON oim.order_item_id = oi.order_item_id
112 INNER JOIN {$orders_table} o ON o.id = oi.order_id
113 WHERE oi.order_item_type = 'line_item'
114 AND oim.meta_key = '_product_id'
115 AND oim.meta_value = %d
116 AND o.status IN ( {$placeholders} )",
117 array_merge( [ $bundle_id ], $paid_statuses )
118 ) );
119
120 $offset = ( $page - 1 ) * $per_page;
121 $order_ids = $wpdb->get_col( $wpdb->prepare(
122 "SELECT DISTINCT oi.order_id
123 FROM {$items_table} oi
124 INNER JOIN {$meta_table} oim ON oim.order_item_id = oi.order_item_id
125 INNER JOIN {$orders_table} o ON o.id = oi.order_id
126 WHERE oi.order_item_type = 'line_item'
127 AND oim.meta_key = '_product_id'
128 AND oim.meta_value = %d
129 AND o.status IN ( {$placeholders} )
130 ORDER BY oi.order_id ASC
131 LIMIT %d OFFSET %d",
132 array_merge( [ $bundle_id ], $paid_statuses, [ $per_page, $offset ] )
133 ) );
134 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
135
136 $processed = 0;
137 $granted_downloads = 0;
138 $synced_orders = 0;
139
140 foreach ( $order_ids as $order_id ) {
141 $order = Helper::get_order( (int) $order_id );
142 if ( is_wp_error( $order ) || ! $order ) {
143 continue;
144 }
145
146 $result = $this->sync_order( $order, $bundle_id, $bundle_items );
147
148 $processed++;
149 $granted_downloads += $result['downloads'];
150 if ( ! empty( $result['new_products'] ) ) {
151 $synced_orders++;
152 }
153 }
154
155 $done = ( $offset + $per_page ) >= $total;
156
157 return rest_ensure_response( [
158 'total' => $total,
159 'page' => $page,
160 'per_page' => $per_page,
161 'processed' => $processed,
162 'granted_downloads' => $granted_downloads,
163 'synced_orders' => $synced_orders,
164 'done' => $done,
165 'next_page' => $done ? null : ( $page + 1 ),
166 ] );
167 }
168
169 /**
170 * Ensure one order carries every current bundle item.
171 *
172 * Merges items missing from the order line's `_bundles` snapshot, grants
173 * download permissions for the newly-added products, and fires an action so
174 * add-ons can backfill their own grants (e.g. licenses).
175 *
176 * @param AbstractOrder $order
177 * @param int $bundle_id
178 * @param array $bundle_items Current bundle contents from BundledProduct::get_bundles().
179 *
180 * @return array{downloads:int,new_products:int[]}
181 */
182 protected function sync_order( AbstractOrder $order, int $bundle_id, array $bundle_items ): array {
183 $downloads = 0;
184 $new_products = [];
185
186 foreach ( $order->get_line_product_items() as $item ) {
187 if ( 'bundled' !== $item->get_product_type() || $bundle_id !== $item->get_product_id() ) {
188 continue;
189 }
190
191 $snapshot = (array) $item->get_meta( '_bundles' );
192 $existing_ids = array_map( 'intval', array_filter( wp_list_pluck( $snapshot, 'product_id' ) ) );
193
194 $added = false;
195 foreach ( $bundle_items as $bundle_item ) {
196 $pid = (int) ( $bundle_item['product_id'] ?? 0 );
197 if ( ! $pid || in_array( $pid, $existing_ids, true ) ) {
198 continue;
199 }
200
201 $snapshot[] = $bundle_item;
202 $existing_ids[] = $pid;
203 $new_products[] = $pid;
204 $added = true;
205 }
206
207 if ( $added ) {
208 $item->update_meta_data( '_bundles', $snapshot );
209 $item->save_meta_data();
210 }
211 }
212
213 $new_products = array_values( array_unique( $new_products ) );
214
215 foreach ( $new_products as $pid ) {
216 $downloads += DownloadPermissionHooks::init()->grant_product_to_order( $order, $pid );
217 }
218
219 if ( $new_products ) {
220 /**
221 * Fires after newly-added bundle items are granted to an existing purchaser's order.
222 *
223 * @param AbstractOrder $order The buyer's order.
224 * @param int[] $new_products Product IDs newly granted on this order.
225 * @param int $bundle_id The bundle product being synced.
226 */
227 do_action( 'storeengine/bundle/order_synced', $order, $new_products, $bundle_id );
228 }
229
230 return [
231 'downloads' => $downloads,
232 'new_products' => $new_products,
233 ];
234 }
235 }
236