PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.7
4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 All 140 releases
learnpress / inc / gateways / subscriptions / class-lp-subscription-manager.php

class-lp-subscription-manager.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.3.7, at inc/gateways/subscriptions/class-lp-subscription-manager.php

702 lines 25.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Subscription orchestration service for gateway webhooks.
4 *
5 * @since 4.3.4
6 */
7
8 defined( 'ABSPATH' ) || exit();
9
10 if ( ! class_exists( 'LP_Subscription_Manager' ) ) {
11 class LP_Subscription_Manager {
12 /**
13 * @var LP_Subscription_Manager|null
14 */
15 protected static $_instance = null;
16
17 const STATUS_TRIAL = 'trial';
18 const STATUS_ACTIVATED = 'activated';
19 const STATUS_RENEWED = 'renewed';
20 const STATUS_CANCELLED = 'cancelled';
21 const STATUS_EXPIRED = 'expired';
22 const STATUS_SUSPENDED = 'suspended';
23
24 /**
25 * Get singleton instance of subscription manager.
26 *
27 * @return LP_Subscription_Manager
28 */
29 public static function instance(): LP_Subscription_Manager {
30 if ( is_null( self::$_instance ) ) {
31 self::$_instance = new static();
32 }
33
34 return self::$_instance;
35 }
36
37 /**
38 * Process a normalized subscription webhook event.
39 *
40 * Flow:
41 * - Build deterministic event id.
42 * - Resolve parent order.
43 * - Check order state for duplicates (order-state-based idempotency).
44 * - Route by event type (activate, renew, fail, cancel, expire, update).
45 * - Sync subscription meta and update order state.
46 *
47 * @param LP_Gateway_Abstract $gateway
48 * @param array $event Normalized payload from gateway::normalize_subscription_event().
49 *
50 * @return array{
51 * status:string,
52 * event_id:string,
53 * event_type:string,
54 * status_code:int,
55 * order_id?:int,
56 * renewal_order_id?:int,
57 * message?:string
58 * }
59 */
60 public function process_webhook_event( LP_Gateway_Abstract $gateway, array $event ): array {
61 $gateway_id = $gateway->get_id();
62 $event_id = sanitize_text_field( (string) ( $event['event_id'] ?? '' ) );
63 $event_type = sanitize_key( (string) ( $event['event_type'] ?? '' ) );
64
65 if ( empty( $event_id ) ) {
66 $event_id = md5( wp_json_encode( $event ) );
67 }
68
69 $response = array(
70 'status' => 'ignored',
71 'event_id' => $event_id,
72 'event_type' => $event_type,
73 'status_code' => 200,
74 );
75
76 try {
77 $order_subscription_id = $this->resolve_parent_order_id( $event );
78 $order_subscription = $order_subscription_id ? learn_press_get_order( $order_subscription_id ) : false;
79 $this->validate_parent_subscription_binding( $order_subscription, $event );
80
81 if ( $this->is_event_already_handled( $event_type, $event_id, $order_subscription, $event ) ) {
82 return array(
83 'status' => 'duplicate',
84 'event_id' => $event_id,
85 'event_type' => $event_type,
86 'status_code' => 200,
87 );
88 }
89
90 if ( $order_subscription ) {
91 $this->sync_subscription_meta( $order_subscription->get_id(), $event );
92 }
93
94 switch ( $event_type ) {
95 case 'subscription_activated':
96 if ( ! $order_subscription ) {
97 throw new Exception( __( 'Order not found.', 'learnpress' ) );
98 }
99
100 $this->update_subscription_status( $order_subscription->get_id(), 'active' );
101 // Team convention: activated means the subscription order becomes completed.
102 $this->mark_parent_payment_completed( $order_subscription, $event );
103 $order_subscription->add_note( __( 'Subscription activated.', 'learnpress' ) );
104 do_action( 'learn-press/subscription/activated', $order_subscription->get_id(), $event, $gateway_id );
105
106 $response['status'] = 'success';
107 $response['order_id'] = $order_subscription->get_id();
108 break;
109 case 'initial_payment_succeeded':
110 if ( ! $order_subscription ) {
111 throw new Exception( __( 'Parent subscription order not found.', 'learnpress' ) );
112 }
113
114 $this->update_subscription_status( $order_subscription->get_id(), 'active' );
115 $this->mark_parent_payment_completed( $order_subscription, $event );
116 $order_subscription->add_note( __( 'Initial subscription payment succeeded.', 'learnpress' ) );
117 do_action( 'learn-press/subscription/initial-payment-succeeded', $order_subscription->get_id(), $event, $gateway_id );
118
119 $response['status'] = 'success';
120 $response['order_id'] = $order_subscription->get_id();
121 break;
122 case 'renewal_payment_succeeded':
123 if ( ! $order_subscription ) {
124 throw new Exception( __( 'Parent subscription order not found.', 'learnpress' ) );
125 }
126
127 $this->update_subscription_status( $order_subscription->get_id(), 'active' );
128 /*
129 * PayPal may report the first successful charge as PAYMENT.SALE.COMPLETED.
130 * If the parent subscription order is still not completed, treat this as
131 * the initial payment instead of creating a renewal order.
132 */
133 if ( 'paypal' === $gateway_id && ! $order_subscription->is_completed() ) {
134 $this->mark_parent_payment_completed( $order_subscription, $event );
135 $order_subscription->add_note( __( 'Initial subscription payment succeeded.', 'learnpress' ) );
136 do_action( 'learn-press/subscription/initial-payment-succeeded', $order_subscription->get_id(), $event, $gateway_id );
137
138 $response['status'] = 'success';
139 $response['order_id'] = $order_subscription->get_id();
140 break;
141 }
142
143 $order_renew = $this->create_renewal_order( $order_subscription, $event, LP_ORDER_COMPLETED );
144 $order_subscription->add_note( __( 'Subscription renewal payment succeeded.', 'learnpress' ) );
145 do_action( 'learn-press/subscription/renewal-order-created', $order_renew->get_id(), $order_subscription->get_id(), $event, $gateway_id );
146 do_action( 'learn-press/subscription/renewed', $order_subscription->get_id(), $order_renew->get_id(), $event, $gateway_id );
147
148 $response['status'] = 'success';
149 $response['order_id'] = $order_subscription->get_id();
150 $response['renewal_order_id'] = $order_renew->get_id();
151 break;
152 case 'renewal_payment_failed':
153 if ( ! $order_subscription ) {
154 throw new Exception( __( 'Parent subscription order not found.', 'learnpress' ) );
155 }
156
157 $this->update_subscription_status( $order_subscription->get_id(), 'past_due' );
158 $order_renew = $this->create_renewal_order( $order_subscription, $event, LP_ORDER_FAILED );
159 $order_subscription->add_note( __( 'Subscription renewal payment failed.', 'learnpress' ) );
160 do_action( 'learn-press/subscription/renewal-order-created', $order_renew->get_id(), $order_subscription->get_id(), $event, $gateway_id );
161 do_action( 'learn-press/subscription/payment-failed', $order_subscription->get_id(), $order_renew->get_id(), $event, $gateway_id );
162
163 $response['status'] = 'failed';
164 $response['order_id'] = $order_subscription->get_id();
165 $response['renewal_order_id'] = $order_renew->get_id();
166 break;
167 case 'subscription_cancelled':
168 if ( ! $order_subscription ) {
169 throw new Exception( __( 'Parent subscription order not found.', 'learnpress' ) );
170 }
171
172 $this->update_subscription_status( $order_subscription->get_id(), 'cancelled' );
173 $order_subscription->add_note( __( 'Subscription cancelled.', 'learnpress' ) );
174 do_action( 'learn-press/subscription/cancelled', $order_subscription->get_id(), $event, $gateway_id );
175
176 $response['status'] = 'cancelled';
177 $response['order_id'] = $order_subscription->get_id();
178 break;
179 case 'subscription_suspended':
180 if ( ! $order_subscription ) {
181 throw new Exception( __( 'Parent subscription order not found.', 'learnpress' ) );
182 }
183
184 $this->update_subscription_status( $order_subscription->get_id(), 'suspended' );
185 $order_subscription->add_note( __( 'Subscription suspended.', 'learnpress' ) );
186 do_action( 'learn-press/subscription/suspended', $order_subscription->get_id(), $event, $gateway_id );
187
188 $response['status'] = 'suspended';
189 $response['order_id'] = $order_subscription->get_id();
190 break;
191 case 'subscription_expired':
192 if ( ! $order_subscription ) {
193 throw new Exception( __( 'Parent subscription order not found.', 'learnpress' ) );
194 }
195
196 $this->update_subscription_status( $order_subscription->get_id(), 'expired' );
197 $order_subscription->add_note( __( 'Subscription expired.', 'learnpress' ) );
198 do_action( 'learn-press/subscription/expired', $order_subscription->get_id(), $event, $gateway_id );
199
200 $response['status'] = 'expired';
201 $response['order_id'] = $order_subscription->get_id();
202 break;
203 case 'subscription_updated':
204 if ( $order_subscription ) {
205 $order_subscription->add_note( __( 'Subscription updated.', 'learnpress' ) );
206 $response['order_id'] = $order_subscription->get_id();
207 }
208 $response['status'] = 'updated';
209 break;
210 default:
211 $response['status'] = 'ignored';
212 break;
213 }
214 } catch ( Throwable $e ) {
215 $response['status'] = 'error';
216 $response['status_code'] = 400;
217 $response['message'] = $e->getMessage();
218 error_log( 'LP_Subscription_Manager: ' . $e->getMessage() );
219 }
220
221 return $response;
222 }
223
224 /**
225 * Check whether the webhook event outcome is already reflected in order state.
226 *
227 * Replaces the old LP_Subscription_Event_Store transient-based approach with
228 * order-state checks: status, transaction_id, and event_id meta.
229 *
230 * @since 4.3.5
231 *
232 * @param string $event_type Normalized event type.
233 * @param string $event_id Provider event ID.
234 * @param LP_Order|false $order_subscription Resolved subscription order (false when not found).
235 * @param array $event Full normalized event payload.
236 *
237 * @return bool True if the event outcome is already reflected in the database.
238 */
239 private function is_event_already_handled( string $event_type, string $event_id, $order_subscription, array $event ): bool {
240
241 if ( ! $order_subscription instanceof LP_Order ) {
242 return false;
243 }
244
245 $order_subscription_id = $order_subscription->get_id();
246
247 switch ( $event_type ) {
248 case 'subscription_activated':
249 $sub_status = get_post_meta( $order_subscription_id, LP_Gateway_Abstract::META_SUBSCRIPTION_STATUS, true );
250 return in_array( $sub_status, array( 'active', 'trialing' ), true );
251
252 case 'initial_payment_succeeded':
253 $sub_status = get_post_meta( $order_subscription_id, LP_Gateway_Abstract::META_SUBSCRIPTION_STATUS, true );
254 if ( ! in_array( $sub_status, array( 'active', 'trialing' ), true ) || ! $order_subscription->is_completed() ) {
255 return false;
256 }
257
258 $event_transaction_id = sanitize_text_field( (string) ( $event['transaction_id'] ?? '' ) );
259 if ( '' === $event_transaction_id ) {
260 return true;
261 }
262
263 $saved_transaction_id = sanitize_text_field(
264 (string) get_post_meta( $order_subscription_id, '_transaction_id', true )
265 );
266 if ( '' === $saved_transaction_id ) {
267 // Need one more pass to backfill transaction id into parent order.
268 return false;
269 }
270
271 return $saved_transaction_id === $event_transaction_id;
272
273 case 'renewal_payment_succeeded':
274 case 'renewal_payment_failed':
275 $event_transaction_id = sanitize_text_field( (string) ( $event['transaction_id'] ?? '' ) );
276 if ( ! empty( $event_transaction_id ) ) {
277 $parent_transaction_id = sanitize_text_field(
278 (string) get_post_meta( $order_subscription_id, '_transaction_id', true )
279 );
280 if ( '' !== $parent_transaction_id && $parent_transaction_id === $event_transaction_id ) {
281 return true;
282 }
283 }
284
285 $renewal_key = $this->get_renewal_key( $event );
286 if ( ! empty( $renewal_key ) && $this->find_renewal_order_by_key( $order_subscription_id, $renewal_key ) ) {
287 return true;
288 }
289 if ( ! empty( $event_id ) && $this->find_renewal_order_by_event( $order_subscription_id, $event_id ) ) {
290 return true;
291 }
292 return false;
293
294 case 'subscription_cancelled':
295 $current = get_post_meta( $order_subscription_id, LP_Gateway_Abstract::META_SUBSCRIPTION_STATUS, true );
296 return 'cancelled' === $current;
297
298 case 'subscription_suspended':
299 $current = get_post_meta( $order_subscription_id, LP_Gateway_Abstract::META_SUBSCRIPTION_STATUS, true );
300 return 'suspended' === $current;
301
302 case 'subscription_expired':
303 $current = get_post_meta( $order_subscription_id, LP_Gateway_Abstract::META_SUBSCRIPTION_STATUS, true );
304 return 'expired' === $current;
305
306 case 'subscription_updated':
307 $last_event = get_post_meta( $order_subscription_id, LP_Gateway_Abstract::META_SUBSCRIPTION_LAST_EVENT_ID, true );
308 return ! empty( $event_id ) && $last_event === $event_id;
309 default:
310 return false;
311 }
312 }
313
314 /**
315 * Resolve parent order id for an incoming event.
316 *
317 * Resolution priority:
318 * 1) event[parent_order_id]
319 * 2) event[metadata][lp_order_id]
320 * 3) lookup by subscription_id in order meta.
321 *
322 * @param array $event Normalized event payload.
323 *
324 * @return int Parent order id or 0 when not found.
325 */
326 public function resolve_parent_order_id( array $event ): int {
327
328 $order_subscription_id = absint( $event['parent_order_id'] ?? 0 );
329 if ( $order_subscription_id ) {
330 return $order_subscription_id;
331 }
332 $metadata = (array) ( $event['metadata'] ?? array() );
333 if ( ! empty( $metadata['lp_order_id'] ) ) {
334 return absint( $metadata['lp_order_id'] );
335 }
336
337 $subscription_id = sanitize_text_field( (string) ( $event['subscription_id'] ?? '' ) );
338 if ( empty( $subscription_id ) ) {
339 return 0;
340 }
341
342 $order_ids = get_posts(
343 array(
344 'post_type' => LP_ORDER_CPT,
345 'post_status' => 'any',
346 'posts_per_page' => 1,
347 'fields' => 'ids',
348 'meta_query' => array(
349 array(
350 'key' => LP_Gateway_Abstract::META_SUBSCRIPTION_ID,
351 'value' => $subscription_id,
352 ),
353 ),
354 )
355 );
356
357 if ( empty( $order_ids ) ) {
358 return 0;
359 }
360
361 return absint( $order_ids[0] );
362 }
363
364 /**
365 * Persist shared subscription meta onto a parent order.
366 *
367 * This keeps gateway/provider identifiers available for future lookups
368 * and reconciliation.
369 *
370 * @param int $order_id
371 * @param array $event Normalized event payload.
372 *
373 * @return void
374 */
375 public function sync_subscription_meta( int $order_id, array $event ) {
376
377 if ( ! empty( $event['subscription_id'] ) ) {
378 update_post_meta( $order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_ID, sanitize_text_field( (string) $event['subscription_id'] ) );
379 }
380 if ( ! empty( $event['customer_id'] ) ) {
381 update_post_meta( $order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_CUSTOMER_ID, sanitize_text_field( (string) $event['customer_id'] ) );
382 }
383
384 if ( ! empty( $event['price_id'] ) ) {
385 update_post_meta( $order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_PLAN_ID, sanitize_text_field( (string) $event['price_id'] ) );
386 }
387 if ( ! empty( $event['event_id'] ) ) {
388 update_post_meta( $order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_LAST_EVENT_ID, sanitize_text_field( (string) $event['event_id'] ) );
389 }
390 if ( ! empty( $event['metadata'] ) && is_array( $event['metadata'] ) ) {
391 update_post_meta( $order_id, '_lp_subscription_metadata', $event['metadata'] );
392 }
393 }
394
395 /**
396 * Update current subscription status stored on parent order.
397 *
398 * @param int $order_id
399 * @param string $status Normalized status slug (active/past_due/cancelled/...).
400 * @return void
401 */
402 public function update_subscription_status( int $order_id, string $status ) {
403
404 update_post_meta( $order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_STATUS, sanitize_key( $status ) );
405 }
406 /**
407 * Mark parent order paid when initial subscription activation succeeds.
408 *
409 * @param LP_Order $order_subscription
410 * @param array $event Normalized event payload.
411 *
412 * @return void
413 */
414 protected function mark_parent_payment_completed( LP_Order $order_subscription, array $event ) {
415 $transaction_id = sanitize_text_field( (string) ( $event['transaction_id'] ?? '' ) );
416 if ( ! $order_subscription->is_completed() ) {
417 $order_subscription->payment_complete( $transaction_id );
418 return;
419 }
420
421 // Parent order is already completed (e.g. completed at ACTIVATED). Backfill
422 // transaction id when later webhook provides it.
423 if ( '' !== $transaction_id ) {
424 $saved_transaction_id = sanitize_text_field(
425 (string) get_post_meta( $order_subscription->get_id(), '_transaction_id', true )
426 );
427 if ( '' === $saved_transaction_id ) {
428 update_post_meta( $order_subscription->get_id(), '_transaction_id', $transaction_id );
429 }
430 }
431 }
432 /**
433 * Create or reuse a renewal child order for a renewal event.
434 *
435 * Idempotency strategy:
436 * - First by renewal_key (strongest provider-derived key).
437 * - Fallback by event_id.
438 *
439 * @param LP_Order $order_subscription
440 * @param array $event Normalized event payload.
441 * @param string $target_status Target order status for renewal result.
442 *
443 * @return LP_Order Existing or newly-created renewal order.
444 * @throws Exception
445 */
446 public function create_renewal_order( LP_Order $order_subscription, array $event, string $target_status = LP_ORDER_PENDING ): LP_Order {
447
448 $renewal_key = $this->get_renewal_key( $event );
449 if ( ! empty( $renewal_key ) ) {
450 $existing = $this->find_renewal_order_by_key( $order_subscription->get_id(), $renewal_key );
451 if ( $existing ) {
452 return $existing;
453 }
454 }
455
456 $event_id = sanitize_text_field( (string) ( $event['event_id'] ?? '' ) );
457 if ( ! empty( $event_id ) ) {
458 $existing = $this->find_renewal_order_by_event( $order_subscription->get_id(), $event_id );
459 if ( $existing ) {
460 return $existing;
461 }
462 }
463
464 $renewal_total = isset( $event['amount'] ) && (float) $event['amount'] > 0 ? (float) $event['amount'] : (float) $order_subscription->get_total();
465 $renewal_subtotal = isset( $event['amount'] ) && (float) $event['amount'] > 0 ? (float) $event['amount'] : (float) $order_subscription->get_subtotal();
466 $renewal_currency = ! empty( $event['currency'] ) ? sanitize_text_field( (string) $event['currency'] ) : $order_subscription->get_currency();
467
468 $order_renew = new LP_Order();
469 $order_renew->set_parent_id( $order_subscription->get_id() );
470 $order_renew->set_user_id( $order_subscription->get_user_id() );
471 $order_renew->set_checkout_email( $order_subscription->get_checkout_email() );
472 $order_renew->set_status( LP_ORDER_PENDING );
473 $order_renew->set_created_via( 'subscription' );
474 $order_renew->set_currency( $renewal_currency );
475 $order_renew->set_total( $renewal_total );
476 $order_renew->set_subtotal( $renewal_subtotal );
477 $order_renew->set_data( 'payment_method', $order_subscription->get_data( 'payment_method', '' ) );
478 $order_renew->set_data( 'payment_method_title', $order_subscription->get_payment_method_title() );
479
480 $renewal_order_id = $order_renew->save();
481 if ( empty( $renewal_order_id ) ) {
482 throw new Exception( __( 'Cannot create renewal order.', 'learnpress' ) );
483 }
484
485 $this->copy_parent_order_items_to_renewal( $order_subscription, $order_renew );
486 if ( ! empty( $event_id ) ) {
487 update_post_meta( $renewal_order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_EVENT_ID, $event_id );
488 }
489 if ( ! empty( $event['subscription_id'] ) ) {
490 update_post_meta( $renewal_order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_ID, sanitize_text_field( (string) $event['subscription_id'] ) );
491 }
492
493 if ( LP_ORDER_COMPLETED !== $target_status && ! empty( $event['transaction_id'] ) ) {
494 update_post_meta( $renewal_order_id, '_transaction_id', sanitize_text_field( (string) $event['transaction_id'] ) );
495 }
496 if ( ! empty( $renewal_key ) ) {
497 update_post_meta( $renewal_order_id, LP_Gateway_Abstract::META_SUBSCRIPTION_RENEWAL_KEY, $renewal_key );
498 }
499
500 if ( LP_ORDER_COMPLETED === $target_status ) {
501 $order_renew->payment_complete( (string) ( $event['transaction_id'] ?? '' ) );
502 } else {
503 $order_renew->update_status( $target_status );
504 }
505
506 $order_renew->add_note(
507 sprintf(
508 /* translators: %s: parent order number */
509 __( 'Subscription renewal generated from parent order %s.', 'learnpress' ),
510 $order_subscription->get_order_number()
511 )
512 );
513
514 return $order_renew;
515 }
516
517 /**
518 * Ensure webhook subscription id matches the resolved parent order binding.
519 *
520 * This prevents applying a callback payload to a parent order when provider
521 * identifiers do not match (e.g. billing_agreement_id mismatch on PayPal).
522 *
523 * @param LP_Order|false $order_subscription Resolved subscription order.
524 * @param array $event Normalized event payload.
525 *
526 * @return void
527 * @throws Exception
528 */
529 protected function validate_parent_subscription_binding( $order_subscription, array $event ) {
530
531 if ( ! $order_subscription instanceof LP_Order ) {
532 return;
533 }
534
535 $event_subscription_id = sanitize_text_field( (string) ( $event['subscription_id'] ?? '' ) );
536 if ( empty( $event_subscription_id ) ) {
537 return;
538 }
539
540 $saved_subscription_id = sanitize_text_field(
541 (string) get_post_meta( $order_subscription->get_id(), LP_Gateway_Abstract::META_SUBSCRIPTION_ID, true )
542 );
543 if ( ! empty( $saved_subscription_id ) && $saved_subscription_id !== $event_subscription_id ) {
544 throw new Exception( __( 'Subscription id does not match parent order.', 'learnpress' ) );
545 }
546 }
547 /**
548 * Find renewal child order by parent id + provider event id.
549 *
550 * @param int $parent_order_id
551 * @param string $event_id
552 *
553 * @return LP_Order|false
554 */
555 protected function find_renewal_order_by_event( int $parent_order_id, string $event_id ) {
556 $order_ids = get_posts(
557 array(
558 'post_type' => LP_ORDER_CPT,
559 'post_status' => 'any',
560 'post_parent' => $parent_order_id,
561 'posts_per_page' => 1,
562 'fields' => 'ids',
563 'meta_query' => array(
564 array(
565 'key' => LP_Gateway_Abstract::META_SUBSCRIPTION_EVENT_ID,
566 'value' => $event_id,
567 ),
568 ),
569 )
570 );
571
572 if ( empty( $order_ids ) ) {
573 return false;
574 }
575
576 return learn_press_get_order( absint( $order_ids[0] ) );
577 }
578
579 /**
580 * Find renewal child order by parent id + renewal key.
581 *
582 * @param int $parent_order_id
583 * @param string $renewal_key
584 *
585 * @return LP_Order|false
586 */
587 protected function find_renewal_order_by_key( int $parent_order_id, string $renewal_key ) {
588 $order_ids = get_posts(
589 array(
590 'post_type' => LP_ORDER_CPT,
591 'post_status' => 'any',
592 'post_parent' => $parent_order_id,
593 'posts_per_page' => 1,
594 'fields' => 'ids',
595 'meta_query' => array(
596 array(
597 'key' => LP_Gateway_Abstract::META_SUBSCRIPTION_RENEWAL_KEY,
598 'value' => $renewal_key,
599 ),
600 ),
601 )
602 );
603
604 if ( empty( $order_ids ) ) {
605 return false;
606 }
607
608 return learn_press_get_order( absint( $order_ids[0] ) );
609 }
610
611 /**
612 * Build stable renewal dedupe key from event payload.
613 *
614 * Priority:
615 * - explicit `renewal_key` provided by gateway mapping.
616 * - fallback `subscription_id|transaction_id` composite.
617 *
618 * @param array $event Normalized event payload.
619 *
620 * @return string Non-empty dedupe key when enough data exists.
621 */
622 protected function get_renewal_key( array $event ): string {
623 $renewal_key = sanitize_text_field( (string) ( $event['renewal_key'] ?? '' ) );
624 if ( ! empty( $renewal_key ) ) {
625 return $renewal_key;
626 }
627
628 $subscription_id = sanitize_text_field( (string) ( $event['subscription_id'] ?? '' ) );
629 $transaction_id = sanitize_text_field( (string) ( $event['transaction_id'] ?? '' ) );
630 if ( ! empty( $subscription_id ) && ! empty( $transaction_id ) ) {
631 return $subscription_id . '|' . $transaction_id;
632 }
633
634 return '';
635 }
636
637 /**
638 * Clone parent order items/meta into a renewal child order.
639 *
640 * Copies quantity/subtotal/total from the parent item meta so renewal
641 * records remain auditable even when catalog prices change later.
642 *
643 * @param LP_Order $order_subscription
644 * @param LP_Order $order_renew
645 *
646 * @return void
647 */
648 protected function copy_parent_order_items_to_renewal( LP_Order $order_subscription, LP_Order $order_renew ) {
649 $parent_items = $order_subscription->get_items();
650 if ( empty( $parent_items ) || ! is_array( $parent_items ) ) {
651 return;
652 }
653
654 foreach ( $parent_items as $parent_item ) {
655 $parent_item_id = absint( is_array( $parent_item ) ? ( $parent_item['id'] ?? 0 ) : ( $parent_item->order_item_id ?? 0 ) );
656 $item_id = absint( is_array( $parent_item ) ? ( $parent_item['item_id'] ?? 0 ) : ( $parent_item->item_id ?? 0 ) );
657 $item_type = is_array( $parent_item ) ? ( $parent_item['item_type'] ?? '' ) : ( $parent_item->item_type ?? '' );
658 $item_name = is_array( $parent_item ) ? ( $parent_item['name'] ?? '' ) : ( $parent_item->order_item_name ?? '' );
659
660 if ( empty( $item_id ) || empty( $item_type ) ) {
661 continue;
662 }
663
664 $quantity = 1;
665 $subtotal = 0;
666 $total = 0;
667
668 if ( $parent_item_id > 0 ) {
669 $quantity_meta = learn_press_get_order_item_meta( $parent_item_id, '_quantity', true );
670 $subtotal_meta = learn_press_get_order_item_meta( $parent_item_id, '_subtotal', true );
671 $total_meta = learn_press_get_order_item_meta( $parent_item_id, '_total', true );
672
673 if ( '' !== $quantity_meta ) {
674 $quantity = (float) $quantity_meta;
675 }
676 if ( '' !== $subtotal_meta ) {
677 $subtotal = (float) $subtotal_meta;
678 }
679 if ( '' !== $total_meta ) {
680 $total = (float) $total_meta;
681 }
682 }
683
684 $new_item_id = $order_renew->add_item(
685 array(
686 'item_id' => $item_id,
687 'item_type' => $item_type,
688 'order_item_name' => $item_name ? $item_name : get_the_title( $item_id ),
689 'quantity' => $quantity > 0 ? $quantity : 1,
690 'subtotal' => $subtotal,
691 'total' => $total,
692 )
693 );
694
695 if ( ! $new_item_id ) {
696 continue;
697 }
698 }
699 }
700 }
701 }
702