PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 All 164 releases
← All changes | includes/Services/Cloud_Print_Trigger_Service.php +245 -88 1.9.16 → 1.10.20 View file →
@@ -12,8 +12,20 @@
12 12 /**
13 13 * Cloud_Print_Trigger_Service class.
14 14 */
15 15 class Cloud_Print_Trigger_Service {
16 + /**
17 + * The Cloud Print settings option key.
18 + *
19 + * Read directly rather than through Cloud_Print_Section: the section's
20 + * read() decorates rows with live printer status, which means outbound
21 + * HTTP, and this class runs on woocommerce_new_order and
22 + * woocommerce_order_status_changed. Network calls do not belong on the
23 + * checkout path. It also redacts secrets, which the printer-poll and
24 + * PrintNode paths need intact.
25 + *
26 + * @var string
27 + */
16 28 const OPTION = 'woocommerce_pos_settings_cloud_print';
17 29
18 30 /**
19 31 * Cron hook used to submit a PrintNode job out-of-band (never on checkout).
@@ -20,8 +32,18 @@
20 32 */
21 33 const CRON_SUBMIT = 'wcpos_cloud_print_submit';
22 34
23 35 /**
36 + * Seconds before an abandoned assignment lock may be reclaimed.
37 + */
38 + const ASSIGNMENT_LOCK_TTL = 120;
39 +
40 + /**
41 + * Default assignment trigger: never print before the customer has paid.
42 + */
43 + const DEFAULT_TRIGGER = 'paid';
44 +
45 + /**
24 46 * Job store.
25 47 *
26 48 * @var Print_Job_Service
27 49 */
@@ -27,8 +49,20 @@
27 49 */
28 50 private $jobs;
29 51
30 52 /**
53 + * Order ids whose woocommerce_payment_complete fired this request.
54 + *
55 + * The payment event is the authoritative "paid" signal: WCPOS routes
56 + * payment_complete() to a merchant-configured per-gateway status (see
57 + * Orders::payment_complete_order_status), which may not be one of
58 + * wc_get_is_paid_statuses() — e.g. on-hold for account sales.
59 + *
60 + * @var array<int, bool>
61 + */
62 + private $payment_completed = array();
63 +
64 + /**
31 65 * Printer registry.
32 66 *
33 67 * @var Cloud_Print_Registry
34 68 */
@@ -41,11 +75,56 @@
41 75 $this->jobs = new Print_Job_Service();
42 76 $this->registry = new Cloud_Print_Registry();
43 77 add_action( 'woocommerce_new_order', array( $this, 'handle_order' ), 20, 1 );
44 78 add_action( 'woocommerce_order_status_changed', array( $this, 'handle_order' ), 20, 1 );
79 + add_action( 'woocommerce_payment_complete', array( $this, 'handle_paid_order' ), 20, 1 );
45 80 }
46 81
47 82 /**
83 + * Handle payment completing for an order.
84 + *
85 + * Runs after WC_Order::payment_complete() has moved the order to its
86 + * post-payment status, which a status-changed callback may have already
87 + * seen as a non-paid status. Remember the paid signal, then re-evaluate.
88 + *
89 + * @param int $order_id Order ID.
90 + */
91 + public function handle_paid_order( $order_id ): void {
92 + $this->payment_completed[ (int) $order_id ] = true;
93 + $this->handle_order( $order_id );
94 + }
95 +
96 + /**
97 + * Normalize an assignment trigger to a supported value.
98 + *
99 + * Shared by the order-event path, sanitize-on-write, and normalize-on-read
100 + * so the three defaulting sites cannot drift: a drifted default here would
101 + * print receipts for unpaid orders.
102 + *
103 + * @param mixed $trigger Raw trigger value.
104 + *
105 + * @return string created|paid.
106 + */
107 + public static function normalize_trigger( $trigger ): string {
108 + return \in_array( $trigger, array( 'created', 'paid' ), true ) ? $trigger : self::DEFAULT_TRIGGER;
109 + }
110 +
111 + /**
112 + * Order-meta key holding how many jobs an assignment has already fired.
113 + *
114 + * Keyed by the same triple the job count filters on, so two rules that
115 + * differ only by trigger keep separate marks. Hashed because a template id
116 + * can be an arbitrary virtual slug and meta keys have a length limit.
117 + *
118 + * @param string $printer_id Printer id.
119 + * @param string $template_id Template id.
120 + * @param string $trigger Normalized trigger.
121 + */
122 + private static function fired_meta_key( string $printer_id, string $template_id, string $trigger ): string {
123 + return '_wcpos_cp_fired_' . md5( $printer_id . "\0" . $template_id . "\0" . $trigger );
124 + }
125 +
126 + /**
48 127 * Create jobs for an order according to the configured assignments.
49 128 *
50 129 * @param int $order_id Order ID.
51 130 */
@@ -83,43 +162,131 @@
83 162 $scope = isset( $assignment['scope'] ) ? (string) $assignment['scope'] : 'every';
84 163 if ( ! $this->scope_matches( $scope, $is_pos ) ) {
85 164 continue;
86 165 }
87 - if ( $this->already_queued( $order->get_id(), (string) $assignment['printer_id'], (string) $assignment['template_id'] ) ) {
166 + $trigger = self::normalize_trigger( $assignment['trigger'] ?? '' );
167 + if ( ! $this->payment_state_matches( $trigger, $order ) ) {
88 168 continue;
89 169 }
90 -
91 - $printer = $this->registry->get_printer( (string) $assignment['printer_id'] );
92 - if ( empty( $printer ) ) {
170 + $printer_id = (string) $assignment['printer_id'];
171 + $template_id = (string) $assignment['template_id'];
172 + $order_id = $order->get_id();
173 + $lock = 'wcpos_cloud_print_assignment_lock_' . md5( $order_id . "\0" . $printer_id . "\0" . $template_id );
174 + if ( ! $this->acquire_assignment_lock( $lock ) ) {
93 175 continue;
94 176 }
95 - $provider = (string) ( $printer['provider'] ?? '' );
177 + try {
178 + // Not a duplicate of the Settings Section's clamp: this one guards
179 + // the output of the woocommerce_pos_cloud_print_assignments filter,
180 + // which Pro substitutes rows into (Cloud_Print_Per_Outlet). Rows
181 + // that arrive through the filter never passed the section's
182 + // sanitizer, so an extension can hand us copies: 999. Keep it.
183 + $copies = min( 5, max( 1, (int) ( $assignment['copies'] ?? 1 ) ) );
184 + // Dedupe per trigger: a created-rule job must not satisfy a
185 + // paid rule for the same printer+template (and vice versa).
186 + // Trigger-less jobs (manual prints, pre-trigger installs)
187 + // still count toward every rule.
188 + $existing = $this->jobs->count(
189 + array(
190 + 'printer_id' => $printer_id,
191 + 'order_id' => $order_id,
192 + 'template_id' => $template_id,
193 + 'trigger' => $trigger,
194 + )
195 + );
196 + // Counting rows alone cannot dedupe: the rows are deletable (by
197 + // the admin, and by the retention purge), and handle_order()
198 + // runs again on every later status change. A deleted receipt
199 + // would then read as never printed and be queued a second time
200 + // — including one the admin had deliberately cancelled. The
201 + // high-water mark survives the rows it counts.
202 + $fired_key = self::fired_meta_key( $printer_id, $template_id, $trigger );
203 + $fired = (int) $order->get_meta( $fired_key );
204 + $shortfall = max( 0, $copies - max( $existing, $fired ) );
205 + if ( 0 === $shortfall ) {
206 + continue;
207 + }
96 208
97 - $template_id = (string) $assignment['template_id'];
98 - $template = Print_Job_Service::load_template( $template_id );
99 - if ( null === $template ) {
100 - continue;
209 + $printer = $this->registry->get_printer( $printer_id );
210 + if ( empty( $printer ) ) {
211 + continue;
212 + }
213 + // Legacy printer rows may lack a stored provider; normalize() maps
214 + // them to the star-cloudprnt default like every other read path.
215 + $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) );
216 +
217 + $template = Print_Job_Service::load_template( $template_id );
218 + if ( null === $template ) {
219 + continue;
220 + }
221 +
222 + for ( $copy = 0; $copy < $shortfall; $copy++ ) {
223 + $job_id = self::enqueue_order_job(
224 + $this->jobs,
225 + $printer_id,
226 + $printer,
227 + $order_id,
228 + $template_id,
229 + $template,
230 + array(),
231 + $trigger
232 + );
233 + if ( $job_id > 0 ) {
234 + ++$fired;
235 + $order->update_meta_data( $fired_key, (string) $fired );
236 + $order->save_meta_data();
237 + }
238 + if ( 0 === $job_id ) {
239 + Logger::log(
240 + sprintf(
241 + 'Cloud print: skipping assignment for printer "%s" — template "%s" is not printable on provider "%s".',
242 + $printer_id,
243 + $template_id,
244 + $provider
245 + )
246 + );
247 + break;
248 + }
249 + }
250 + } finally {
251 + delete_option( $lock );
101 252 }
253 + }
254 + }
102 255
103 - $job_id = self::enqueue_order_job(
104 - $this->jobs,
105 - (string) $assignment['printer_id'],
106 - $printer,
107 - $order->get_id(),
108 - $template_id,
109 - $template
110 - );
111 - if ( 0 === $job_id ) {
112 - Logger::log(
113 - sprintf(
114 - 'Cloud print: skipping assignment for printer "%s" — template "%s" is not printable on provider "%s".',
115 - (string) $assignment['printer_id'],
116 - $template_id,
117 - $provider
118 - )
119 - );
256 + /**
257 + * Acquire the lock covering copy counting and job creation.
258 + *
259 + * @param string $option Lock option name.
260 + */
261 + private function acquire_assignment_lock( string $option ): bool {
262 + $now = time();
263 +
264 + if ( add_option( $option, (string) $now, '', false ) ) {
265 + return true;
266 + }
267 +
268 + $locked_at = get_option( $option, 0 );
269 + if ( (int) $locked_at > 0 && ( $now - (int) $locked_at ) > self::ASSIGNMENT_LOCK_TTL ) {
270 + global $wpdb;
271 + // The value predicate prevents deleting a lock replaced after get_option().
272 + $deleted = $wpdb->delete(
273 + $wpdb->options,
274 + array(
275 + 'option_name' => $option,
276 + 'option_value' => (string) $locked_at,
277 + ),
278 + array( '%s', '%s' )
279 + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic option delete; cache cleared below.
280 + if ( 1 !== $deleted ) {
281 + return false;
120 282 }
283 + wp_cache_delete( $option, 'options' );
284 +
285 + return add_option( $option, (string) $now, '', false );
121 286 }
287 +
288 + return false;
122 289 }
123 290
124 291 /**
125 292 * Enqueue a print job for an order + template, deriving the wire format from
@@ -136,55 +303,39 @@
136 303 * @param int $order_id Order id to render.
137 304 * @param string $template_id Template id (numeric) or virtual slug.
138 305 * @param array $template Loaded template array.
139 306 * @param array $drawer_options Drawer options.
307 + * @param string $trigger Originating rule trigger (created|paid); empty for manual prints.
140 308 *
141 309 * @return int Created job id, or 0 when the template is not printable on the provider.
142 310 */
143 - public static function enqueue_order_job( Print_Job_Service $jobs, string $printer_id, array $printer, int $order_id, string $template_id, array $template, array $drawer_options = array() ): int {
144 - $provider = (string) ( $printer['provider'] ?? '' );
311 + public static function enqueue_order_job( Print_Job_Service $jobs, string $printer_id, array $printer, int $order_id, string $template_id, array $template, array $drawer_options = array(), string $trigger = '' ): int {
312 + // Normalize before EVERY consumer below (drawer options, printability,
313 + // requires_submit) — a legacy row without a provider is star-cloudprnt.
314 + $provider = Provider::normalize( (string) ( $printer['provider'] ?? '' ) );
145 315 $drawer_options = self::drawer_options_for_provider( $provider, $drawer_options );
146 316
147 - if ( 'printnode' === $provider ) {
148 - $fmt = ( new Print_Format_Resolver() )->resolve( $printer, $template );
149 - if ( '' === $fmt['kind'] ) {
150 - return 0;
151 - }
152 -
153 - $job_id = $jobs->create(
154 - array(
155 - 'printer_id' => $printer_id,
156 - 'order_id' => $order_id,
157 - 'template_id' => $template_id,
158 - 'content_type' => $fmt['content_type'],
159 - 'pn_kind' => $fmt['kind'],
160 - 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
161 - 'drawer_connector' => $drawer_options['drawer_connector'],
162 - )
163 - );
164 - if ( $job_id > 0 ) {
165 - wp_schedule_single_event( time(), self::CRON_SUBMIT, array( $job_id ) );
166 - }
167 -
168 - return $job_id;
317 + // Provider::format() owns both halves of the answer for every provider: an
318 + // empty kind means the template cannot be rendered on this printer.
319 + $fmt = Provider::format( $printer, $template );
320 + if ( '' === $fmt['kind'] ) {
321 + return 0;
169 322 }
170 323
171 - $engine = (string) ( $template['engine'] ?? '' );
172 - $wire = Provider::wire_format( $provider, $engine );
173 - if ( null === $wire ) {
174 - return 0;
324 + $job_args = array(
325 + 'printer_id' => $printer_id,
326 + 'content_type' => $fmt['content_type'],
327 + 'order_id' => $order_id,
328 + 'template_id' => $template_id,
329 + 'trigger' => $trigger,
330 + 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
331 + 'drawer_connector' => $drawer_options['drawer_connector'],
332 + );
333 + if ( Provider::stores_job_kind( $provider ) ) {
334 + $job_args['pn_kind'] = $fmt['kind'];
175 335 }
176 336
177 - $job_id = $jobs->create(
178 - array(
179 - 'printer_id' => $printer_id,
180 - 'content_type' => Provider::content_type( $provider ),
181 - 'order_id' => $order_id,
182 - 'template_id' => $template_id,
183 - 'auto_open_drawer' => ! empty( $drawer_options['auto_open_drawer'] ),
184 - 'drawer_connector' => $drawer_options['drawer_connector'],
185 - )
186 - );
337 + $job_id = $jobs->create( $job_args );
187 338
188 339 // Push providers (e.g. Star Online) don't poll us; submit out-of-band.
189 340 if ( $job_id > 0 && Provider::requires_submit( $provider ) ) {
190 341 wp_schedule_single_event( time(), self::CRON_SUBMIT, array( $job_id ) );
@@ -193,12 +344,14 @@
193 344 return $job_id;
194 345 }
195 346
196 347 /**
197 - * Keep drawer metadata scoped to providers implemented by this server change.
348 + * Keep drawer metadata scoped to providers that can act on it.
198 349 *
199 - * Star providers use Star-specific drawer commands and are intentionally not
200 - * changed by the Epson/PrintNode implementation.
350 + * Zeroing it elsewhere is not cosmetic: a job that carries drawer metadata a
351 + * renderer never reads would promise the cashier a drawer kick that never
352 + * fires. Star Online is the remaining opt-out — stario.online renders our
353 + * markup and the markup has no drawer verb.
201 354 *
202 355 * @param string $provider Provider key.
203 356 * @param array $drawer_options Drawer options.
204 357 *
@@ -204,9 +357,9 @@
204 357 *
205 358 * @return array{auto_open_drawer:bool, drawer_connector:string}
206 359 */
207 360 private static function drawer_options_for_provider( string $provider, array $drawer_options ): array {
208 - if ( ! in_array( $provider, array( 'epson-sdp', 'printnode' ), true ) ) {
361 + if ( ! Provider::supports_drawer( $provider ) ) {
209 362 return array(
210 363 'auto_open_drawer' => false,
211 364 'drawer_connector' => 'pin2',
212 365 );
@@ -218,8 +371,32 @@
218 371 );
219 372 }
220 373
221 374 /**
375 + * Whether an assignment trigger applies to this order's payment state.
376 + *
377 + * POS carts ARE orders from the moment the cart is saved (status
378 + * pos-open), and online orders exist at checkout as pending — so
379 + * 'created' fires before the customer has paid. 'paid' (the default)
380 + * accepts any of three signals: a paid status per
381 + * wc_get_is_paid_statuses(), the woocommerce_payment_complete event seen
382 + * this request, or a stored date_paid — the latter two cover gateways
383 + * whose configured post-payment status is not a WC paid status.
384 + *
385 + * @param string $trigger created|paid.
386 + * @param \WC_Order $order The order being processed.
387 + */
388 + private function payment_state_matches( string $trigger, \WC_Order $order ): bool {
389 + if ( 'created' === $trigger ) {
390 + return true;
391 + }
392 +
393 + return $order->is_paid()
394 + || ! empty( $this->payment_completed[ $order->get_id() ] )
395 + || null !== $order->get_date_paid();
396 + }
397 +
398 + /**
222 399 * Whether an assignment scope applies to this order origin.
223 400 *
224 401 * @param string $scope every|pos|online.
225 402 * @param bool $is_pos Whether the order was created via the POS.
@@ -235,26 +412,6 @@
235 412 return ! $is_pos;
236 413 }
237 414
238 415 return false;
239 - }
240 -
241 - /**
242 - * Guard against duplicate jobs for the same order+printer+template.
243 - *
244 - * @param int $order_id Order ID.
245 - * @param string $printer_id Printer ID.
246 - * @param string $template_id Template ID.
247 - */
248 - private function already_queued( int $order_id, string $printer_id, string $template_id ): bool {
249 - $existing = $this->jobs->query(
250 - array(
251 - 'printer_id' => $printer_id,
252 - 'order_id' => $order_id,
253 - 'template_id' => $template_id,
254 - 'limit' => 1,
255 - )
256 - );
257 -
258 - return ! empty( $existing );
259 416 }
260 417 }