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