PluginProbe
Pay with Vipps and MobilePay for WooCommerce / 6.1.5
Pay with Vipps and MobilePay for WooCommerce v6.1.5
6.2.5 6.2.4 6.2.3 6.2.2 6.2.1 6.2.0 6.1.10 6.1.9 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1.0 6.0.5 6.0.4 6.0.3 6.0.2 6.0.1 6.0.0 5.4.3 5.4.2 All 187 releases
woo-vipps / recurring / includes / wc-gateway-vipps-recurring.php

wc-gateway-vipps-recurring.php in Pay with Vipps and MobilePay for WooCommerce 6.1.5, at recurring/includes/wc-gateway-vipps-recurring.php

3,286 lines 120.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use Automattic\WooCommerce\Enums\OrderStatus;
4
5 defined( 'ABSPATH' ) || exit;
6
7 require_once( __DIR__ . '/wc-vipps-models.php' );
8 require_once( __DIR__ . '/wc-vipps-recurring-api.php' );
9
10 /**
11 * WC_Gateway_Vipps class.
12 *
13 * @extends WC_Payment_Gateway
14 */
15 class WC_Gateway_Vipps_Recurring extends WC_Payment_Gateway {
16 /**
17 * Which brand to use, will be one of: vipps, mobilepay
18 */
19 public string $brand;
20
21 /**
22 * Vipps MobilePay merchant serial number
23 */
24 public string $merchant_serial_number;
25
26 /**
27 * Whether Vipps MobilePay Checkout is enabled
28 */
29 public bool $checkout_enabled;
30
31 public string $order_prefix;
32
33 /**
34 * Is test mode active?
35 */
36 public bool $test_mode;
37
38 /**
39 * The default status to give pending renewals
40 */
41 public string $default_renewal_status;
42
43 /**
44 * The default status pending orders that have yet to be captured (reserved charges in Vipps/MobilePay) should be given
45 */
46 public string $default_reserved_charge_status;
47
48 /**
49 * Status where when transitioned to we will attempt to capture the payment
50 */
51 public array $statuses_to_attempt_capture;
52
53 /**
54 * Transition the order status to 'completed' when a renewal order has been charged successfully
55 * regardless of previous status
56 */
57 public bool $transition_renewals_to_completed;
58
59 /**
60 * The amount of charges to check in wp-cron at a time
61 */
62 public int $check_charges_amount;
63
64 /**
65 * The sort order in which we check charges in wp-cron
66 */
67 public string $check_charges_sort_order;
68
69 /**
70 * The reference the *Singleton* instance of this class
71 */
72 private static ?WC_Gateway_Vipps_Recurring $instance = null;
73
74 public WC_Vipps_Recurring_Api $api;
75
76 public ?bool $use_high_performance_order_storage = null;
77
78 public bool $auto_capture_mobilepay = false;
79
80 public ?string $continue_shopping_link_page = null;
81
82 /**
83 * Returns the *Singleton* instance of this class.
84 *
85 * @return WC_Gateway_Vipps_Recurring The *Singleton* instance.
86 */
87 public static function get_instance(): WC_Gateway_Vipps_Recurring {
88 if ( null === self::$instance ) {
89 self::$instance = new self();
90 }
91
92 return self::$instance;
93 }
94
95 /**
96 * Constructor
97 */
98 public function __construct() {
99 $this->id = 'vipps_recurring';
100 $this->method_title = __( 'Vipps/MobilePay Recurring Payments', 'woo-vipps' );
101 $this->method_description = __( 'Vipps/MobilePay Recurring Payments works by redirecting your customers to the Vipps MobilePay portal for confirmation. It creates a payment plan and charges your users on the intervals you specify.', 'woo-vipps' );
102 $this->has_fields = true;
103
104 /*
105 * Do not add 'multiple_subscriptions' to $supports.
106 * Vipps/MobilePay Recurring API does not have any concept of multiple line items at the time of writing this.
107 * It could technically be possible to support this, but it's very confusing for a customer in the Vipps/MobilePay app.
108 * There are a lot of edge cases to think about in order to support this functionality too,
109 * 'process_payment' would have to be rewritten entirely.
110 */
111 $this->supports = [
112 'products',
113 'subscriptions',
114 'refunds',
115 'subscription_cancellation',
116 'subscription_suspension',
117 'subscription_reactivation',
118 'subscription_amount_changes',
119 'subscription_date_changes',
120 'subscription_payment_method_change',
121 'subscription_payment_method_change_customer',
122 'subscription_payment_method_change_admin',
123 ];
124
125 // Load the form fields.
126 $this->init_form_fields();
127
128 // Load the settings.
129 $this->init_settings();
130
131 $this->brand = $this->get_option( 'brand' );
132 $this->title = $this->get_form_fields()['brand']['options'][ $this->brand ];
133 $this->description = str_replace( '{brand}', $this->title, $this->get_option( 'description' ) );
134 $this->enabled = $this->get_option( 'enabled' );
135 $this->test_mode = $this->get_option( 'test_mode' ) === "yes";
136 $this->merchant_serial_number = $this->get_option( 'merchant_serial_number' );
137 $this->checkout_enabled = $this->get_option( 'checkout_enabled' ) === "yes";
138 $this->order_prefix = $this->get_option( 'order_prefix' );
139 $this->default_renewal_status = $this->get_option( 'default_renewal_status' );
140 $this->default_reserved_charge_status = $this->get_option( 'default_reserved_charge_status' );
141 $this->transition_renewals_to_completed = $this->get_option( 'transition_renewals_to_completed' ) === "yes";
142 $this->check_charges_amount = $this->get_option( 'check_charges_amount' );
143 $this->check_charges_sort_order = $this->get_option( 'check_charges_sort_order' );
144 $this->auto_capture_mobilepay = $this->get_option( 'auto_capture_mobilepay' ) === "yes";
145 $this->continue_shopping_link_page = $this->get_option( 'continue_shopping_link_page' );
146
147 if ( WC_VIPPS_RECURRING_TEST_MODE ) {
148 $this->test_mode = true;
149 }
150
151 if ( $this->test_mode ) {
152 $this->merchant_serial_number = $this->get_option( 'test_merchant_serial_number' );
153 }
154
155 // translators: %s: brand name, Vipps or MobilePay
156 $this->order_button_text = sprintf( __( 'Pay with %s', 'woo-vipps' ), $this->title );
157
158 $this->api = new WC_Vipps_Recurring_Api( $this );
159
160 /*
161 * When transitioning an order to these statuses we should
162 * automatically try to capture the charge if it's not already captured
163 */
164 $capture_statuses = [
165 'completed',
166 'processing'
167 ];
168
169 /*
170 * We have to remove the status corresponding to `$this->default_reserved_charge_status` otherwise we end up
171 * prematurely capturing this reserved Vipps/MobilePay charge
172 */
173 $capture_status_transition_id = array_search( str_replace( 'wc-', '', $this->default_reserved_charge_status ), $capture_statuses, true );
174 if ( $capture_status_transition_id ) {
175 unset( $capture_statuses[ $capture_status_transition_id ] );
176 }
177
178 $this->statuses_to_attempt_capture = apply_filters( 'wc_vipps_recurring_captured_statuses', $capture_statuses );
179
180 // If we change a status that is currently on-hold to any of the $capture_statuses we should attempt to capture it
181 foreach ( $this->statuses_to_attempt_capture as $status ) {
182 add_action( 'woocommerce_order_status_' . $status, [ $this, 'maybe_capture_payment' ] );
183 }
184
185 add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', [
186 $this,
187 'append_valid_statuses_for_payment_complete'
188 ] );
189
190 // Auto cancellation flow
191 add_action( 'woocommerce_subscription_status_pending-cancel_to_cancelled', [
192 $this,
193 'maybe_delete_subscription'
194 ], 99999 );
195 add_action( 'woocommerce_subscription_status_pending_to_cancelled', [
196 $this,
197 'maybe_delete_subscription'
198 ], 99999 );
199 add_action( 'woocommerce_new_subscription', [ $this, 'maybe_delete_subscription_later' ] );
200
201 // Hijack cancellation email
202 remove_action( 'woocommerce_subscription_status_updated', [
203 WC_Subscriptions_Email::class,
204 'send_cancelled_email'
205 ], 10 );
206 add_action( 'woocommerce_subscription_status_updated', [ $this, 'overwrite_send_cancelled_email' ], 10, 2 );
207 add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, [
208 $this,
209 'process_admin_options'
210 ] );
211
212 add_action( 'woocommerce_account_view-order_endpoint', [ $this, 'check_charge_status' ], 1 );
213
214 add_action( 'set_logged_in_cookie', [ $this, 'set_cookie_on_current_request' ] );
215
216 add_action( 'woocommerce_scheduled_subscription_payment_' . $this->id, [
217 $this,
218 'scheduled_subscription_payment'
219 ], 1, 2 );
220
221 add_action( 'woocommerce_subscription_cancelled_' . $this->id, [
222 $this,
223 'maybe_cancel_subscription',
224 ] );
225
226 add_action( 'woocommerce_before_thankyou', [ $this, 'maybe_process_redirect_order' ], 1 );
227
228 add_action( 'woocommerce_subscription_failing_payment_method_updated_' . $this->id, [
229 $this,
230 'update_failing_payment_method'
231 ], 10, 2 );
232
233 /*
234 * When changing the payment method for a WooCommerce Subscription to Vipps MobilePay, let WooCommerce Subscription
235 * know that the payment method for that subscription should not be changed immediately. Instead, it should
236 * wait for the go ahead in cron, after the user confirmed the payment method change with Vipps MobilePay.
237 */
238 add_filter( 'woocommerce_subscriptions_update_payment_via_pay_shortcode', [
239 $this,
240 'indicate_async_payment_method_update'
241 ], 10, 2 );
242
243 // Tell WooCommerce about our custom payment meta fields
244 add_action( 'woocommerce_subscription_payment_meta', [ $this, 'add_subscription_payment_meta' ], 10, 2 );
245
246 // Validate custom payment meta fields
247 add_action( 'woocommerce_subscription_validate_payment_meta', [
248 $this,
249 'validate_subscription_payment_meta'
250 ], 10, 2 );
251
252 // Handle subscription switches (free upgrades & downgrades)
253 add_action( 'woocommerce_subscriptions_switched_item', [ $this, 'handle_subscription_switch_completed' ] );
254
255 // If we are performing a subscription switch to Vipps Recurring we need to take a payment
256 // todo: Figure out how to force a redirect to Vipps here. We do need to sign a new agreement in a lot of cases...
257 // todo: Vipps has a 10x multiplier limit for the agreement pricing.
258 add_filter( 'woocommerce_cart_needs_payment', [ $this, 'cart_needs_payment' ], 100, 2 );
259
260 /*
261 * Handle in app updates when a subscription status changes, typically when status transitions to
262 * 'pending-cancel', 'cancelled' or 'pending-cancel' to any other status
263 */
264 add_action( 'woocommerce_subscription_status_updated', [
265 $this,
266 'maybe_handle_subscription_status_transitions'
267 ], 10, 3 );
268
269 // Delete idempotency key when renewal/resubscribe happens
270 add_action( 'wcs_resubscribe_order_created', [ $this, 'delete_resubscribe_meta' ] );
271 add_action( 'wcs_renewal_order_created', [ $this, 'delete_renewal_meta' ], 5, 1 );
272
273 // Cancel DUE charge if order transitions to 'cancelled' or 'failed'
274 $cancel_due_charge_statuses = apply_filters( 'wc_vipps_recurring_cancel_due_charge_statuses', [
275 'cancelled',
276 'failed'
277 ] );
278
279 foreach ( $cancel_due_charge_statuses as $status ) {
280 add_action( 'woocommerce_order_status_' . $status, [ $this, 'maybe_cancel_due_charge' ] );
281 }
282
283 add_action( 'woocommerce_payment_complete', [ $this, 'after_renew_early_from_another_gateway' ] );
284
285 add_filter( 'woocommerce_payment_complete_order_status', [
286 $this,
287 'prevent_backwards_transition_on_completed_order'
288 ], 100, 3 );
289
290 add_action( 'woocommerce_order_after_calculate_totals', [ $this, 'update_agreement_price_in_app' ], 10, 2 );
291
292 // Woo Subscriptions uses `wp_safe_redirect()` during a gateway change, which will not allow us to redirect to the Vipps MobilePay API
293 // Unless we whitelist the domains specifically
294 // https://developer.vippsmobilepay.com/docs/knowledge-base/servers/
295 add_filter( 'allowed_redirect_hosts', function ( $hosts ) {
296 return array_merge( $hosts, [
297 // Production servers
298 'api.vipps.no',
299 'api.mobilepay.dk',
300 'api.mobilepay.fi',
301 'pay.vipps.no',
302 'pay.mobilepay.dk',
303 'pay.mobilepay.fi',
304 // MT servers
305 'apitest.vipps.no',
306 'pay-mt.vipps.no',
307 'pay-mt.mobilepay.dk',
308 'pay-mt.mobilepay.fi'
309 ] );
310 } );
311 }
312
313 /**
314 * Indicate to WooCommerce Subscriptions that the payment method change for Vipps/MobilePay Recurring Payments
315 * should be asynchronous.
316 *
317 * WC_Subscriptions_Change_Payment_Gateway::change_payment_method_via_pay_shortcode uses the
318 * result to decide whether to change the payment method information on the subscription
319 * right away or not.
320 *
321 * In our case, the payment method will not be updated until after the user confirms the
322 * payment method change with Vipps MobilePay. Once that's done, we'll take care of finishing
323 * the payment method update with the subscription.
324 *
325 * @param bool $should_update Current value of whether the payment method should be updated immediately.
326 * @param string $new_payment_method The new payment method name.
327 *
328 * @return bool Whether the subscription's payment method should be updated on checkout or async when a response is returned.
329 */
330 public function indicate_async_payment_method_update( bool $should_update, string $new_payment_method ): bool {
331 if ( $this->id === $new_payment_method ) {
332 $should_update = false;
333 }
334
335 return $should_update;
336 }
337
338 /**
339 * @param $subscription
340 * @param $renewal_order
341 */
342 public function update_failing_payment_method( $subscription, $renewal_order ): void {
343 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $renewal_order ) );
344 }
345
346 /**
347 * @param $order_id
348 *
349 * @throws WC_Vipps_Recurring_Exception
350 */
351 public function process_redirect_payment( $order_id ): void {
352 $order = wc_get_order( $order_id );
353 if ( ! is_object( $order ) ) {
354 return;
355 }
356
357 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $order );
358 if ( $payment_method !== $this->id ) {
359 // If this is not the payment method, an agreement would not be available.
360 return;
361 }
362
363 // check latest charge status
364 $status = $this->check_charge_status( $order_id );
365
366 WC_Vipps_Recurring_Logger::log( sprintf( "[%s] process_redirect_payment: charge status is: %s", $order_id, $status ) );
367 }
368
369 /**
370 * @param $order_id
371 *
372 * @throws WC_Vipps_Recurring_Exception
373 */
374 public function maybe_process_redirect_order( $order_id ): void {
375 if ( ! is_order_received_page() ) {
376 return;
377 }
378
379 $order = wc_get_order( $order_id );
380 if ( $order->get_payment_method() !== $this->id ) {
381 return;
382 }
383
384 $this->process_redirect_payment( $order_id );
385 }
386
387 /**
388 * Check if we are using the new HPOS feature from WooCommerce
389 * This function is used for backwards compatibility in certain places
390 */
391 public function use_high_performance_order_storage(): bool {
392 if ( $this->use_high_performance_order_storage === null ) {
393 $this->use_high_performance_order_storage = function_exists( 'wc_get_container' ) && // 4.4.0
394 function_exists( 'wc_get_page_screen_id' ) && // Exists in the HPOS update
395 class_exists( "Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController" ) &&
396 wc_get_container()->get( Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled();
397 }
398
399 return $this->use_high_performance_order_storage;
400 }
401
402 /**
403 * Retrieves the latest charge for an order if any. False if none or invalid agreement id
404 *
405 * @param $order
406 *
407 * @return bool|WC_Vipps_Charge
408 * @throws WC_Vipps_Recurring_Exception
409 */
410 public function get_latest_charge_from_order( $order ) {
411 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $order );
412
413 if ( ! $agreement_id ) {
414 return false;
415 }
416
417 $charge_id = WC_Vipps_Recurring_Helper::get_charge_id_from_order( $order );
418 $charge = false;
419
420 if ( $charge_id ) {
421 try {
422 $charge = $this->api->get_charge( $agreement_id, $charge_id );
423 } catch ( WC_Vipps_Recurring_Temporary_Exception $e ) {
424 // Let the caller decide how to handle transient failures so we don't conflate them with "no charge exists"
425 throw $e;
426 } catch ( Exception $e ) {
427 $charge = $this->get_latest_charge_for_agreement( $order, $agreement_id, $charge_id );
428 }
429 } else {
430 $charge = $this->get_latest_charge_for_agreement( $order, $agreement_id, $charge_id );
431 }
432
433 return $charge;
434 }
435
436 /**
437 * @param object|WC_Order $order
438 * @param string $agreement_id
439 * @param string $charge_id
440 *
441 * @return false|null|WC_Vipps_Charge
442 * @throws WC_Vipps_Recurring_Config_Exception
443 * @throws WC_Vipps_Recurring_Exception
444 * @throws WC_Vipps_Recurring_Temporary_Exception
445 */
446 public function get_latest_charge_for_agreement( $order, string $agreement_id, string $charge_id ) {
447 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Failed checking charge directly for charge: %s and agreement: %s. This might mean we have not set the right charge id somewhere. Finding latest charge instead.', WC_Vipps_Recurring_Helper::get_id( $order ), $charge_id, $agreement_id ) );
448
449 $charges = $this->api->get_charges_for( $agreement_id );
450
451 // return false if there is no charge
452 // this will tell us if this was directly captured or not
453 if ( count( $charges ) === 0 ) {
454 return false;
455 }
456
457 $charge = null;
458 $latestDate = null;
459
460 // find the latest charge by due date
461 foreach ( $charges as $row ) {
462 $currentDate = $row->due;
463
464 if ( $latestDate === null || $currentDate > $latestDate ) {
465 $latestDate = $currentDate;
466 $charge = $row;
467 }
468 }
469
470 return $charge;
471 }
472
473 /**
474 * Receives the agreement associated with an order
475 *
476 * @param object|WC_Order $order
477 *
478 * @return bool|WC_Vipps_Agreement
479 * @throws WC_Vipps_Recurring_Exception
480 */
481 public function get_agreement_from_order( $order ) {
482 $order_agreement_id = WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID );
483
484 $subscriptions = wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] );
485 $subscription = array_shift( $subscriptions );
486 $subscription_agreement_id = $subscription ? WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID ) : '';
487
488 $primary_id = $order_agreement_id ?: $subscription_agreement_id;
489 $fallback_id = ( $order_agreement_id && $subscription_agreement_id && $subscription_agreement_id !== $order_agreement_id ) ? $subscription_agreement_id : '';
490
491 if ( ! $primary_id ) {
492 return false;
493 }
494
495 try {
496 return $this->api->get_agreement( $primary_id );
497 } catch ( WC_Vipps_Recurring_Exception $e ) {
498 if ( $e->response_code !== 404 || ! $fallback_id ) {
499 return false;
500 }
501
502 try {
503 $agreement = $this->api->get_agreement( $fallback_id );
504 } catch ( Exception $retry_e ) {
505 return false;
506 }
507
508 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Order agreement id %s returned 404, falling back to subscription agreement id %s and updating order meta', WC_Vipps_Recurring_Helper::get_id( $order ), $primary_id, $fallback_id ) );
509
510 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, $fallback_id );
511 $order->save();
512
513 return $agreement;
514 } catch ( Exception $e ) {
515 return false;
516 }
517 }
518
519 /**
520 * @param object|WC_Order $order
521 */
522 public function unlock_order( $order ): void {
523 WC_Vipps_Recurring_Helper::update_meta_data( $order, '_vipps_recurring_locked_for_update_time', 0 );
524 $order->save();
525 }
526
527 /**
528 * @param $order_id
529 * @param bool $skip_lock
530 *
531 * @return string
532 * @throws WC_Vipps_Recurring_Config_Exception
533 * @throws WC_Vipps_Recurring_Exception
534 * @throws WC_Vipps_Recurring_Temporary_Exception
535 */
536 public function check_charge_status( $order_id, bool $skip_lock = false ): string {
537 if ( empty( $order_id ) || absint( $order_id ) <= 0 ) {
538 return 'INVALID';
539 }
540
541 $order = wc_get_order( absint( $order_id ) );
542
543 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $order );
544 if ( $payment_method !== $this->id ) {
545 // if this is not the payment method, an agreement would not be available.
546 return 'INVALID';
547 }
548
549 $is_renewal = wcs_order_contains_renewal( $order );
550
551 // we need to tell WooCommerce that this is in fact a scheduled payment that should be retried in the case of failure.
552 if ( $is_renewal ) {
553 add_filter( 'wcs_is_scheduled_payment_attempt', '__return_true' );
554 }
555
556 if ( ! $this->use_high_performance_order_storage() ) {
557 clean_post_cache( WC_Vipps_Recurring_Helper::get_id( $order ) );
558 }
559
560 // hold on to the lock for 30 seconds
561 $lock = (int) WC_Vipps_Recurring_Helper::get_meta( $order, '_vipps_recurring_locked_for_update_time' );
562 if ( ( $lock && $lock > time() - 30 ) && ! $skip_lock ) {
563 return 'SUCCESS';
564 }
565
566 WC_Vipps_Recurring_Helper::update_meta_data( $order, '_vipps_recurring_locked_for_update_time', time() );
567 $order->save();
568
569 $order_status = $order->get_status();
570
571 if ( $order_status === 'failed' ) {
572 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
573 $this->unlock_order( $order );
574
575 return 'CANCELLED';
576 }
577
578 $agreement = $this->get_agreement_from_order( $order );
579
580 if ( ! $agreement ) {
581 // If there is no agreement we can't complete Checkout orders. Let Checkout deal with this through an action.
582 $order_initial = WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_ORDER_INITIAL );
583
584 if ( $order_initial && $order->get_payment_method() === $this->id ) {
585 do_action( 'wc_vipps_recurring_check_charge_status_no_agreement', $order );
586 }
587
588 $this->unlock_order( $order );
589
590 return 'INVALID';
591 }
592
593 $zero_amount_result = $this->handle_zero_amount_order( $order, $agreement, $is_renewal );
594 if ( $zero_amount_result !== null ) {
595 $this->unlock_order( $order );
596
597 return $zero_amount_result;
598 }
599
600 try {
601 /** @var WC_Vipps_Charge $charge */
602 $charge = $this->get_latest_charge_from_order( $order );
603 } catch ( WC_Vipps_Recurring_Temporary_Exception $e ) {
604 // Transient failure (rate limit, no response). Keep the order in the pending queue and retry on the next tick.
605 $this->unlock_order( $order );
606
607 return 'SUCCESS';
608 }
609
610 if ( ! $charge ) {
611 WC_Vipps_Recurring_Logger::log( sprintf( "[%s] Unable to determine latest charge, stopped checking order", $order_id ) );
612
613 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
614 $this->unlock_order( $order );
615
616 return 'CANCELLED';
617 }
618
619 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_ID, $charge->id );
620 WC_Vipps_Recurring_Helper::set_latest_api_status_for_order( $order, $charge->status );
621
622 $initial = empty( WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_ORDER_INITIAL ) )
623 && ! $is_renewal;
624 $pending_charge = $initial ? 1 : (int) WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING );
625 $did_fail = WC_Vipps_Recurring_Helper::is_charge_failed_for_order( $order );
626
627 if ( ( $charge->status === WC_Vipps_Charge::STATUS_CANCELLED && ! $is_renewal ) || $charge->status === WC_Vipps_Charge::STATUS_REFUNDED ) {
628 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
629 $this->unlock_order( $order );
630
631 return 'CANCELLED';
632 }
633
634 // If payment has already been captured, this function is redundant, unless the charge failed
635 if ( ! $pending_charge && ! $did_fail ) {
636 $this->unlock_order( $order );
637
638 return 'SUCCESS';
639 }
640
641 $is_captured = ! in_array( $charge->status, [
642 WC_Vipps_Charge::STATUS_PENDING,
643 WC_Vipps_Charge::STATUS_RESERVED
644 ], true ) && $agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE;
645
646 $capture_result = $this->attempt_charge_capture( $order, $order_id, $is_captured, $order_status );
647 if ( $capture_result !== null ) {
648 $this->unlock_order( $order );
649
650 return $capture_result;
651 }
652
653 return $this->finalize_charge_check( $order, $charge, $agreement, $initial, $is_captured );
654 }
655
656 /**
657 * Handle the special case where the order's subtotal is zero. There is no charge to look up; the
658 * agreement status alone tells us whether to complete, fail, or wait for the order.
659 *
660 * @return string|null One of 'SUCCESS' / 'CANCELLED' when this branch applies, null otherwise.
661 * @throws WC_Vipps_Recurring_Config_Exception
662 * @throws WC_Vipps_Recurring_Exception
663 * @throws WC_Vipps_Recurring_Temporary_Exception
664 */
665 private function handle_zero_amount_order( $order, WC_Vipps_Agreement $agreement, bool $is_renewal ): ?string {
666 if ( ! WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_ORDER_ZERO_AMOUNT ) || $is_renewal ) {
667 return null;
668 }
669
670 // if there's a campaign with a price of 0 we can complete the order immediately
671 if ( $agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE ) {
672 $this->complete_order( $order, $agreement->id );
673
674 $order->add_order_note( __( 'The subtotal is zero, the order is free for this subscription period.', 'woo-vipps' ) );
675 $order->save();
676 }
677
678 // if EXPIRED or STOPPED we can fail this order
679 if ( in_array( $agreement->status, [
680 WC_Vipps_Agreement::STATUS_EXPIRED,
681 WC_Vipps_Agreement::STATUS_STOPPED
682 ], true ) ) {
683 $this->check_charge_agreement_cancelled( $order, $agreement );
684
685 return 'CANCELLED';
686 }
687
688 return 'SUCCESS';
689 }
690
691 /**
692 * Decide whether an uncaptured charge should be captured now, skipped (cancelled order), or
693 * left for the normal finalize path. Returns the status string when this branch terminates the
694 * check, or null to continue with finalize_charge_check().
695 *
696 * @return string|null
697 * @throws WC_Vipps_Recurring_Config_Exception
698 * @throws WC_Vipps_Recurring_Exception
699 * @throws WC_Vipps_Recurring_Temporary_Exception
700 */
701 private function attempt_charge_capture( $order, $order_id, bool $is_captured, string $order_status ): ?string {
702 if ( $is_captured ) {
703 return null;
704 }
705
706 // Stop processing if the order has a cancelled status — there's nothing left to capture.
707 if ( $order_status === 'cancelled' ) {
708 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] This order has not been captured, but has a cancelled status. Skipping capture attempt.', WC_Vipps_Recurring_Helper::get_id( $order ) ) );
709 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
710
711 return 'SUCCESS';
712 }
713
714 $is_mobilepay_auto_capture = $this->brand === WC_Vipps_Recurring_Helper::BRAND_MOBILEPAY && $this->auto_capture_mobilepay;
715
716 // MobilePay auto-releases and refunds reservations after 7 days, so we capture eagerly when configured.
717 if ( $is_mobilepay_auto_capture ) {
718 // maybe_capture_payment re-fetches the order, so persist META_CHARGE_ID and META_LATEST_API_STATUS first.
719 $order->save();
720
721 if ( $this->maybe_capture_payment( $order_id ) ) {
722 $order->add_order_note( __( 'MobilePay payments are automatically captured to prevent the payment reservation from automatically getting cancelled after 7 days.', 'woo-vipps' ) );
723
724 return 'SUCCESS';
725 }
726
727 // Capture failed; fall through to finalize so we still record the current charge state.
728 return null;
729 }
730
731 $is_direct_capture = WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_ORDER_DIRECT_CAPTURE );
732 $has_completed_state = $order_status === 'completed';
733
734 // Capture if direct capture is enabled or the order has already been marked Completed.
735 if ( $is_direct_capture || $has_completed_state ) {
736 $order->save();
737 $this->maybe_capture_payment( $order_id );
738
739 return 'SUCCESS';
740 }
741
742 return null;
743 }
744
745 /**
746 * Write the final state for the charge, unlock the order, and hand off to process_order_charge.
747 * Returns 'CANCELLED' if the agreement is expired/stopped, otherwise 'SUCCESS'.
748 */
749 private function finalize_charge_check( $order, WC_Vipps_Charge $charge, WC_Vipps_Agreement $agreement, bool $initial, bool $is_captured ): string {
750 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_CAPTURED, $is_captured );
751
752 if ( $initial ) {
753 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_ORDER_INITIAL, true );
754 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING, true );
755 }
756
757 $this->unlock_order( $order );
758
759 $order->save();
760
761 if ( ! $this->use_high_performance_order_storage() ) {
762 clean_post_cache( WC_Vipps_Recurring_Helper::get_id( $order ) );
763 }
764
765 $this->process_order_charge( $order, $charge );
766
767 if ( in_array( $agreement->status, [
768 WC_Vipps_Agreement::STATUS_STOPPED,
769 WC_Vipps_Agreement::STATUS_EXPIRED
770 ], true ) ) {
771 $this->check_charge_agreement_cancelled( $order, $agreement, $charge );
772
773 return 'CANCELLED';
774 }
775
776 return 'SUCCESS';
777 }
778
779 /**
780 * @param $order
781 * @param WC_Vipps_Agreement $agreement
782 * @param bool|WC_Vipps_Charge $charge
783 *
784 * @throws WC_Vipps_Recurring_Config_Exception
785 * @throws WC_Vipps_Recurring_Exception
786 * @throws WC_Vipps_Recurring_Temporary_Exception
787 */
788 public function check_charge_agreement_cancelled( $order, WC_Vipps_Agreement $agreement, $charge = false ): void {
789 $order->update_status( 'cancelled', __( 'The agreement was cancelled or expired in Vipps/MobilePay', 'woo-vipps' ) );
790
791 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING, false );
792 $order->save();
793
794 // cancel charge
795 if ( $charge && in_array( $charge->status, [
796 WC_Vipps_Charge::STATUS_DUE,
797 WC_Vipps_Charge::STATUS_PENDING
798 ], true ) ) {
799 $idempotency_key = $this->get_idempotency_key( $order );
800
801 $this->api->cancel_charge( $agreement->id, $charge->id, $idempotency_key );
802 }
803 }
804
805 /**
806 * @param $order
807 * @param $transaction_id
808 */
809 public function complete_order( $order, $transaction_id ): void {
810 $order->payment_complete( $transaction_id );
811
812 // Controlled by the `transition_renewals_to_completed` setting
813 // Only applicable to renewal orders
814 if ( wcs_order_contains_renewal( $order ) && $order->get_status() !== 'completed' ) {
815 $order->update_status( $this->transition_renewals_to_completed ? 'completed' : 'processing' );
816 }
817
818 // Unlock the order and make sure we tell our cronjob to stop periodically checking the status of this order
819 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING, false );
820 $this->unlock_order( $order );
821
822 if ( is_callable( [ $order, 'save' ] ) ) {
823 $order->save();
824 }
825
826 do_action( 'wc_vipps_recurring_after_payment_complete', $order );
827 }
828
829 /**
830 * @param $order
831 * @param WC_Vipps_Charge|null $charge
832 */
833 public function process_order_charge( $order, ?WC_Vipps_Charge $charge = null ): void {
834 if ( ! $charge ) {
835 // No charge
836 return;
837 }
838
839 // If payment has already been completed, this function is redundant.
840 if ( ! WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING ) ) {
841 return;
842 }
843
844 do_action( 'wc_vipps_recurring_before_process_order_charge', $order );
845
846 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_ID, $charge->id );
847 $transaction_id = WC_Vipps_Recurring_Helper::get_transaction_id_for_order( $order );
848
849 // Reduce stock
850 $reduce_stock = $charge->status === WC_Vipps_Charge::STATUS_CHARGED || in_array( $charge->status, [
851 WC_Vipps_Charge::STATUS_DUE,
852 WC_Vipps_Charge::STATUS_PENDING
853 ], true );
854
855 if ( $reduce_stock ) {
856 $order_stock_reduced = WC_Vipps_Recurring_Helper::is_stock_reduced_for_order( $order );
857
858 if ( ! $order_stock_reduced ) {
859 WC_Vipps_Recurring_Helper::reduce_stock_for_order( $order );
860 }
861 }
862
863 // status: CHARGED
864 if ( $charge->status === WC_Vipps_Charge::STATUS_CHARGED ) {
865 $this->complete_order( $order, $charge->id );
866
867 /* translators: Vipps/MobilePay Charge ID */
868 $message = sprintf( __( 'Charge completed (Charge ID: %s)', 'woo-vipps' ), $charge->id );
869 $order->add_order_note( $message );
870
871 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Completed order for charge: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id ) );
872 }
873
874 // status: RESERVED
875 // not auto captured, so we need to put the order status to `$this->default_reserved_charge_status`
876 if ( empty( $transaction_id ) && $charge->status === WC_Vipps_Charge::STATUS_RESERVED
877 && ! wcs_order_contains_renewal( $order ) ) {
878 WC_Vipps_Recurring_Helper::set_transaction_id_for_order( $order, $charge->id );
879
880 $message = __( 'Waiting for you to capture the payment', 'woo-vipps' );
881 $order->update_status( $this->default_reserved_charge_status, $message );
882 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Charge reserved: %s (%s)', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id, $charge->status ) );
883 }
884
885 // status: RESERVED
886 // Part of a renewal, so we need to put the order status to `$this->default_renewal_status` or completed and charge the payment.
887 if ( empty( $transaction_id ) && $charge->status === WC_Vipps_Charge::STATUS_RESERVED
888 && wcs_order_contains_renewal( $order ) ) {
889 // NOTE: should we respect $order->needs_processing() here?
890 // If the answer is yes, we also need to make sure we create a RESERVE_CAPTURE charge when creating new charges as well.
891 if ( $this->maybe_capture_payment( WC_Vipps_Recurring_Helper::get_id( $order ) ) ) {
892 WC_Vipps_Recurring_Helper::set_transaction_id_for_order( $order, $charge->id );
893 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Charge captured for renewal order: %s (%s)', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id, $charge->status ) );
894
895 $status = $this->transition_renewals_to_completed ? 'completed' : $this->default_renewal_status;
896 $order->update_status( $status );
897 }
898 }
899
900 // status: DUE or PENDING
901 // when DUE, we need to check that it becomes another status in a cron
902 $initial = WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_ORDER_INITIAL )
903 && ! wcs_order_contains_renewal( $order );
904
905 if ( ! $initial && ! $transaction_id && ( $charge->status === WC_Vipps_Charge::STATUS_DUE
906 || ( $charge->status === WC_Vipps_Charge::STATUS_PENDING
907 && wcs_order_contains_renewal( $order ) ) ) ) {
908 WC_Vipps_Recurring_Helper::set_transaction_id_for_order( $order, $charge->id );
909
910 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_CAPTURED, true );
911
912 $order->update_status( $this->default_renewal_status, $this->get_due_charge_note( $charge ) );
913
914 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Charge due or pending: %s (%s)', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id, $charge->status ) );
915 }
916
917 // status: CANCELLED
918 if ( $charge->status === WC_Vipps_Charge::STATUS_CANCELLED && ! wcs_order_contains_renewal( $order ) ) {
919 $order->update_status( 'cancelled', __( 'Vipps/MobilePay payment cancelled.', 'woo-vipps' ) );
920 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
921
922 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Charge cancelled: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id ) );
923 }
924
925 // status: FAILED, or CANCELLED (renewal)
926 if ( in_array( $charge->status, [
927 WC_Vipps_Charge::STATUS_FAILED,
928 WC_Vipps_Charge::STATUS_CANCELLED
929 ], true ) ) {
930 $order->update_status( 'failed', __( 'Vipps/MobilePay payment failed.', 'woo-vipps' ) );
931 WC_Vipps_Recurring_Helper::set_order_charge_failed( $order, $charge );
932
933 // if subscription status is already pending-cancel, we should cancel it completely
934 // this is not WooCommerce Subscription's job as this issue is caused by Vipps' internal retry logic
935 $subscriptions = $this->get_subscriptions_for_order( $order );
936 foreach ( $subscriptions as $subscription ) {
937 if ( $subscription->get_status() !== 'pending-cancel' ) {
938 continue;
939 }
940
941 $subscription->update_status( 'cancelled' );
942 }
943
944 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Charge failed: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id ) );
945 }
946
947 // if status was FAILED, but no longer is
948 if ( $charge->status !== WC_Vipps_Charge::STATUS_FAILED
949 && WC_Vipps_Recurring_Helper::is_charge_failed_for_order( $order )
950 && in_array( $charge->status, [ WC_Vipps_Charge::STATUS_PROCESSING, 'DUE', 'PENDING' ], true ) ) {
951 WC_Vipps_Recurring_Helper::set_order_charge_not_failed( $order, $charge->id );
952 }
953
954 $order->save();
955 }
956
957 /**
958 * Proceed with current request using new login session (to ensure consistent nonce).
959 *
960 * @param $cookie
961 */
962 public function set_cookie_on_current_request( $cookie ): void {
963 $_COOKIE[ LOGGED_IN_COOKIE ] = $cookie;
964 }
965
966 /**
967 * Returns all supported currencies for this payment method.
968 *
969 * @return array
970 * @version 4.0.0
971 * @since 4.0.0
972 */
973 public function get_supported_currency(): array {
974 return apply_filters(
975 'wc_vipps_recurring_supported_currencies',
976 [
977 'NOK',
978 'DKK',
979 'EUR',
980 'SEK'
981 ]
982 );
983 }
984
985 /**
986 * Checks to see if all criteria is met before showing payment method.
987 *
988 * @return bool
989 * @version 4.0.0
990 * @since 4.0.0
991 */
992 public function is_available(): bool {
993 if ( ! in_array( get_woocommerce_currency(), $this->get_supported_currency(), true ) ) {
994 return false;
995 }
996
997 return parent::is_available();
998 }
999
1000 /**
1001 * Triggered when it's time to charge a subscription
1002 *
1003 * @param $amount_to_charge
1004 * @param $order
1005 *
1006 * @return bool
1007 *
1008 * @throws Exception
1009 */
1010 public function scheduled_subscription_payment( $amount_to_charge, $order ): bool {
1011 try {
1012 return $this->process_subscription_payment( $amount_to_charge, $order );
1013 } catch ( Exception $e ) {
1014 // if we reach this point we consider the error to be completely unrecoverable.
1015 $order->update_status( 'failed' );
1016
1017 /* translators: Error message */
1018 $message = sprintf( __( 'Failed creating a charge: %s', 'woo-vipps' ), $e->getMessage() );
1019 $order->add_order_note( $message );
1020
1021 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Error in process_subscription_payment: %s', $order->get_id(), $e->getMessage() ) );
1022
1023 return false;
1024 }
1025 }
1026
1027 /**
1028 * Triggered when a subscription is cancelled
1029 *
1030 * @param WC_Subscription $subscription
1031 *
1032 * @throws WC_Vipps_Recurring_Exception
1033 */
1034 public function maybe_cancel_subscription( $subscription ): void {
1035 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $subscription );
1036 if ( $payment_method !== $this->id ) {
1037 // If this is not the payment method, an agreement would not be available.
1038 return;
1039 }
1040
1041 // Prevent temporary cancellations from reaching this code
1042 $new_status = $subscription->get_status();
1043 if ( $new_status !== 'cancelled' ) {
1044 return;
1045 }
1046
1047 $subscription_id = WC_Vipps_Recurring_Helper::get_id( $subscription );
1048
1049 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $subscription );
1050 if ( $agreement_id === null ) {
1051 return;
1052 }
1053
1054 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] maybe_cancel_subscription for agreement: %s', $subscription_id, $agreement_id ) );
1055
1056 $agreement = $this->api->get_agreement( $agreement_id );
1057
1058 if ( $agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE ) {
1059 $this->maybe_handle_subscription_status_transitions( $subscription, $new_status, 'active' );
1060 $this->maybe_update_subscription_details_in_app( WC_Vipps_Recurring_Helper::get_id( $subscription ) );
1061
1062 // Woo Subscriptions sets the next payment date to 0 when a subscription is cancelled, thus we have to calculate it ourselves.
1063 $last_payment_time = max( $subscription->get_time( 'last_order_date_created' ), $subscription->get_time( 'last_order_date_paid' ) );
1064 $next_payment = wcs_add_time( $subscription->get_billing_interval( 'edit' ), $subscription->get_billing_period( 'edit' ), $last_payment_time, 'offset_site_time' );
1065
1066 if ( ! wp_next_scheduled( 'woocommerce_vipps_recurring_cancel_subscription', [
1067 $subscription_id,
1068 $agreement_id
1069 ] ) ) {
1070 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Scheduled a cancellation event for agreement %s at timestamp: %s', $subscription_id, $agreement_id, $next_payment ) );
1071
1072 wp_schedule_single_event( $next_payment, 'woocommerce_vipps_recurring_cancel_subscription', [
1073 $subscription_id,
1074 $agreement_id
1075 ] );
1076 }
1077 }
1078 }
1079
1080 /**
1081 * @param int $order_id
1082 * @param null $amount
1083 * @param string $reason
1084 *
1085 * @throws Exception
1086 */
1087 public function process_refund( $order_id, $amount = null, $reason = '' ): ?bool {
1088 $order = wc_get_order( $order_id );
1089 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $order );
1090 $charge_id = WC_Vipps_Recurring_Helper::get_charge_id_from_order( $order );
1091
1092 $created = $order->get_date_created( null );
1093 if ( $created ) {
1094 $diff = ( new DateTime() )->diff( $created );
1095
1096 if ( $diff->days > 365 ) {
1097 /* translators: %s is the days as an integer since the order was created */
1098 $err = sprintf( __( 'You cannot refund a charge that was made more than 365 days ago. This order was created %s days ago.', 'woo-vipps' ), $diff->days );
1099 throw new \RuntimeException( $err );
1100 }
1101 }
1102
1103 try {
1104 if ( $amount !== null ) {
1105 $amount = WC_Vipps_Recurring_Helper::get_vipps_amount( $amount );
1106 }
1107
1108 $this->api->refund_charge( $agreement_id, $charge_id, $amount, $reason );
1109
1110 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_refund for charge: %s and agreement: %s', $order_id, $charge_id, $agreement_id ) );
1111
1112 return true;
1113 } catch ( WC_Vipps_Recurring_Temporary_Exception $e ) {
1114 $msg = __( 'A temporary error occurred when refunding a payment through Vipps MobilePay. Please ensure the order is refunded manually or reset the order to "Processing" and try again.', 'woo-vipps' );
1115 throw new \RuntimeException( $msg );
1116 } catch ( WC_Vipps_Recurring_Exception $e ) {
1117 // attempt to cancel charge instead
1118 if ( (float) $order->get_remaining_refund_amount() === 0.00 ) {
1119 $idempotency_key = $this->get_idempotency_key( $order );
1120
1121 $this->api->cancel_charge( $agreement_id, $charge_id, $idempotency_key );
1122
1123 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING, false );
1124 $order->save();
1125
1126 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_refund cancelled charge instead of refunding: %s and agreement: %s', $order_id, $charge_id, $agreement_id ) );
1127
1128 return true;
1129 }
1130
1131 // if the remaining refund amount is not equal to the amount we're trying to refund
1132 // that probably means we are trying to partially refund a charge that hasn't yet cleared
1133 if ( (float) $order->get_remaining_refund_amount() !== 0.00 ) {
1134 $err = __( 'You can not partially refund a pending or due charge. Please wait till the payment clears first or refund the full amount instead.', 'woo-vipps' );
1135 throw new \RuntimeException( $err );
1136 }
1137
1138 $err = __( 'An unexpected error occurred while refunding a payment in Vipps/MobilePay.', 'woo-vipps' );
1139 throw new \RuntimeException( $err );
1140 }
1141 }
1142
1143 /**
1144 * @param $order
1145 *
1146 * @return mixed|string
1147 */
1148 public function get_idempotency_key( $order ) {
1149 $idempotency_key = WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_ORDER_IDEMPOTENCY_KEY );
1150
1151 if ( ! $idempotency_key ) {
1152 $idempotency_key = $this->generate_idempotency_key( $order );
1153 }
1154
1155 return $idempotency_key;
1156 }
1157
1158 /**
1159 * @param $order
1160 *
1161 * @return string
1162 */
1163 protected function generate_idempotency_key( $order ): string {
1164 $idempotency_key = $this->api->generate_idempotency_key();
1165
1166 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_ORDER_IDEMPOTENCY_KEY, $idempotency_key );
1167 $order->save();
1168
1169 return $idempotency_key;
1170 }
1171
1172 /**
1173 * @param $amount
1174 * @param WC_Order $renewal_order
1175 *
1176 * @return bool
1177 * @throws WC_Vipps_Recurring_Config_Exception
1178 * @throws WC_Vipps_Recurring_Exception
1179 * @throws WC_Vipps_Recurring_Temporary_Exception
1180 * @throws WC_Data_Exception
1181 */
1182 public function process_subscription_payment( $amount, $renewal_order ): bool {
1183 $renewal_order_id = WC_Vipps_Recurring_Helper::get_id( $renewal_order );
1184
1185 if ( WC_Vipps_Recurring_Helper::order_locked( $renewal_order ) ) {
1186 return true;
1187 }
1188
1189 WC_Vipps_Recurring_Helper::lock_order( $renewal_order );
1190
1191 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_subscription_payment attempting to create charge', $renewal_order->get_id() ) );
1192
1193 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $renewal_order );
1194
1195 if ( ! $agreement_id ) {
1196 throw new WC_Vipps_Recurring_Exception( 'Fatal error: Vipps/MobilePay agreement id does not exist.' );
1197 }
1198
1199 $agreement = $this->api->get_agreement( $agreement_id );
1200 $amount = WC_Vipps_Recurring_Helper::get_vipps_amount( $amount );
1201
1202 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_subscription_payment on agreement: %s', $renewal_order->get_id(), json_encode( $agreement->to_array() ) ) );
1203
1204 /*
1205 * if this is triggered by the Woo Subscriptions retry system we need to delete the data related to the old payment
1206 * and create an entirely new charge.
1207 */
1208 $charge_has_failed = WC_Vipps_Recurring_Helper::is_charge_failed_for_order( $renewal_order );
1209
1210 // if the previous charge is 'FAILED' we can assume this is an automatic retry instead of a normal renewal process.
1211 if ( $charge_has_failed ) {
1212 // check that the currently attached charge is in fact failed
1213 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] it looks like the charge on agreement: %s failed. Deleting renewal meta and creating a new charge.', $renewal_order->get_id(), $agreement->id ) );
1214
1215 // note: delete transaction id as we use this to determine whether to update the order status in check_charge_status.
1216 $renewal_order->set_transaction_id( 0 );
1217 $renewal_order = $this->delete_renewal_meta( $renewal_order );
1218
1219 $this->generate_idempotency_key( $renewal_order );
1220
1221 // clean the post cache for the renewal order to force Woo to fetch meta again.
1222 if ( ! $this->use_high_performance_order_storage() ) {
1223 clean_post_cache( $renewal_order_id );
1224 }
1225 }
1226
1227 $idempotency_key = $this->get_idempotency_key( $renewal_order );
1228
1229 $vipps_order_id = WC_Vipps_Recurring_Helper::generate_vipps_order_id( $renewal_order, $this->order_prefix );
1230 $charge = $this->api->create_charge( $agreement, $idempotency_key, $renewal_order_id, $vipps_order_id, $amount );
1231
1232 WC_Vipps_Recurring_Helper::update_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_CHARGE_ID, $charge['chargeId'] );
1233 WC_Vipps_Recurring_Helper::set_order_as_pending( $renewal_order, $charge['chargeId'] );
1234
1235 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_subscription_payment created charge: %s', $renewal_order->get_id(), json_encode( $charge ) ) );
1236
1237 $charge = $this->api->get_charge( $agreement->id, $charge['chargeId'] );
1238
1239 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_subscription_payment fetched charge: %s', $renewal_order->get_id(), json_encode( $charge->to_array() ) ) );
1240
1241 $this->process_order_charge( $renewal_order, $charge );
1242 $renewal_order->save();
1243
1244 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] process_subscription_payment for charge: %s and agreement: %s', $renewal_order->get_id(), $charge->id, $agreement->id ) );
1245
1246 return true;
1247 }
1248
1249 private function get_due_charge_note( WC_Vipps_Charge $charge ): string {
1250 $timestamp_gmt = WC_Vipps_Recurring_Helper::rfc_3999_date_to_unix( $charge->due );
1251 $date_to_display = ucfirst( wcs_get_human_time_diff( $timestamp_gmt ) );
1252
1253 // translators: Vipps/MobilePay Charge ID, human diff timestamp
1254 return sprintf( __( 'Vipps/MobilePay charge created: %1$s. The charge will be complete %2$s.', 'woo-vipps' ), $charge->id, strtolower( $date_to_display ) );
1255 }
1256
1257 /**
1258 * Maybe capture a payment if it has not already been captured
1259 *
1260 * @param $order_id
1261 *
1262 * @return bool
1263 * @throws WC_Vipps_Recurring_Exception
1264 */
1265 public function maybe_capture_payment( $order_id ): bool {
1266 $order = wc_get_order( $order_id );
1267
1268 if ( ! WC_Vipps_Recurring_Helper::can_capture_charge_for_order( $order ) ) {
1269 return false;
1270 }
1271
1272 return $this->capture_payment( $order );
1273 }
1274
1275 /**
1276 * Capture an initial payment manually
1277 *
1278 * @param $order
1279 *
1280 * @return bool
1281 * @throws WC_Vipps_Recurring_Exception
1282 */
1283 public function capture_payment( $order ): bool {
1284 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $order );
1285 $charge_id = WC_Vipps_Recurring_Helper::get_charge_id_from_order( $order );
1286
1287 try {
1288 $agreement = new WC_Vipps_Agreement( [
1289 'id' => $agreement_id
1290 ] );
1291
1292 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Fetching charge to prepare for capture', WC_Vipps_Recurring_Helper::get_id( $order ) ) );
1293 $charge = $this->api->get_charge( $agreement->id, $charge_id );
1294
1295 $idempotency_key = $this->get_idempotency_key( $order );
1296
1297 if ( $charge->status ) {
1298 if ( ! in_array( $charge->status, [
1299 WC_Vipps_Charge::STATUS_RESERVED,
1300 WC_Vipps_Charge::STATUS_PARTIALLY_CAPTURED
1301 ], true ) ) {
1302 if ( $this->complete_duplicate_capture_if_charged( $order, $charge ) ) {
1303 return true;
1304 }
1305
1306 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Charge does not have the status RESERVED or PARTIALLY_CAPTURED for agreement: %s in capture_payment. Found status: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $agreement_id, $charge->status ) );
1307 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
1308
1309 /* translators: %s: The charge's status */
1310 $order->add_order_note( sprintf( __( 'Could not capture charge because the status is not RESERVED or PARTIALLY_CAPTURED. Found status: %s', 'woo-vipps' ), $charge->status ) );
1311 $order->save();
1312
1313 return false;
1314 }
1315 }
1316
1317 $captured_charge = $this->capture_reserved_charge( $charge, $agreement, $order, $idempotency_key );
1318
1319 WC_Vipps_Recurring_Helper::set_order_as_pending( $order, $captured_charge->id );
1320 $order->save();
1321
1322 $this->process_order_charge( $order, $captured_charge );
1323
1324 if ( ! $this->use_high_performance_order_storage() ) {
1325 clean_post_cache( WC_Vipps_Recurring_Helper::get_id( $order ) );
1326 }
1327
1328 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Finished running capture_payment successfully', WC_Vipps_Recurring_Helper::get_id( $order ) ) );
1329
1330 return true;
1331 } catch ( WC_Vipps_Recurring_Temporary_Exception $e ) {
1332 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Temporary error in capture_payment: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $e->getMessage() ) );
1333 $this->admin_error( __( 'Vipps/MobilePay is temporarily unavailable.', 'woo-vipps' ) );
1334
1335 return false;
1336 } catch ( WC_Vipps_Recurring_Config_Exception $e ) {
1337 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Configuration error in capture_payment: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $e->getMessage() ) );
1338 $this->admin_error( $e->getMessage() );
1339
1340 return false;
1341 } catch ( WC_Vipps_Recurring_Exception $e ) {
1342 // Only treat HTTP 400 as terminal here (e.g. trying to capture a charge older than 180 days).
1343 // Other 4xx/unknown errors are re-thrown so they aren't silently swallowed.
1344 if ( $e->response_code !== 400 ) {
1345 throw $e;
1346 }
1347
1348 if ( $this->is_duplicate_capture_amount_error( $e )
1349 && $this->complete_duplicate_capture_after_status_check( $order, $agreement_id, $charge_id ) ) {
1350 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Treating capture_payment HTTP 400 as duplicate capture after charge status re-check: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $e->getMessage() ) );
1351
1352 return true;
1353 }
1354
1355 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Unrecoverable error in capture_payment (HTTP 400): %s', WC_Vipps_Recurring_Helper::get_id( $order ), $e->getMessage() ) );
1356
1357 // Stop the periodic charge-status check from retrying this capture forever.
1358 WC_Vipps_Recurring_Helper::set_order_as_not_pending( $order );
1359
1360 /* translators: %s: error message from Vipps/MobilePay */
1361 $note = sprintf( __( 'Could not capture Vipps/MobilePay charge (HTTP 400): %s', 'woo-vipps' ), $e->getMessage() );
1362
1363 if ( $order->get_status() !== 'completed' ) {
1364 $order->update_status( 'failed', $note );
1365 } else {
1366 $order->add_order_note( $note );
1367 }
1368
1369 $order->save();
1370
1371 return false;
1372 }
1373 }
1374
1375 private function is_duplicate_capture_amount_error( WC_Vipps_Recurring_Exception $e ): bool {
1376 return stripos( $e->getMessage(), 'higher than the remaining capturable amount' ) !== false;
1377 }
1378
1379 private function complete_duplicate_capture_after_status_check( $order, string $agreement_id, string $charge_id ): bool {
1380 try {
1381 return $this->complete_duplicate_capture_if_charged( $order, $this->api->get_charge( $agreement_id, $charge_id ) );
1382 } catch ( Exception $retry_e ) {
1383 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Could not re-check charge status after duplicate capture error: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $retry_e->getMessage() ) );
1384
1385 return false;
1386 }
1387 }
1388
1389 private function complete_duplicate_capture_if_charged( $order, WC_Vipps_Charge $charge ): bool {
1390 if ( $charge->status !== WC_Vipps_Charge::STATUS_CHARGED ) {
1391 return false;
1392 }
1393
1394 WC_Vipps_Recurring_Helper::set_latest_api_status_for_order( $order, $charge->status );
1395 WC_Vipps_Recurring_Helper::set_order_as_pending( $order, $charge->id );
1396 $order->save();
1397
1398 $this->process_order_charge( $order, $charge );
1399
1400 if ( ! $this->use_high_performance_order_storage() ) {
1401 clean_post_cache( WC_Vipps_Recurring_Helper::get_id( $order ) );
1402 }
1403
1404 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Finished capture_payment from already charged charge: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id ) );
1405
1406 return true;
1407 }
1408
1409 /**
1410 * @param WC_Vipps_Charge $charge
1411 * @param WC_Vipps_Agreement $agreement
1412 * @param $order
1413 * @param string $idempotency_key
1414 */
1415 public function capture_reserved_charge( WC_Vipps_Charge $charge, WC_Vipps_Agreement $agreement, $order, string $idempotency_key ): WC_Vipps_Charge {
1416 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Attempting to capture reserve charge: %s for agreement: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id, $agreement->id ) );
1417
1418 $this->api->capture_reserved_charge( $agreement, $charge, $idempotency_key );
1419
1420 // Set the charge status manually as we can safely assume it was charged here. This avoids making another API request.
1421 $charge->set_status( WC_Vipps_Charge::STATUS_CHARGED );
1422
1423 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Captured reserve charge: %s for agreement: %s', WC_Vipps_Recurring_Helper::get_id( $order ), $charge->id, $agreement->id ) );
1424
1425 return $charge;
1426 }
1427
1428 /**
1429 * @param $order
1430 *
1431 * @return array
1432 */
1433 public function get_subscriptions_for_order( $order ): array {
1434 return WC_Vipps_Recurring_Helper::get_subscriptions_for_order( $order );
1435 }
1436
1437 private function end_gateway_change_checking( $subscription ) {
1438 $subscription->delete_meta_data( WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_WAITING_FOR_GATEWAY_CHANGE );
1439 $subscription->delete_meta_data( '_new_agreement_id' );
1440 $subscription->delete_meta_data( '_old_agreement_id' );
1441 }
1442
1443 /**
1444 * @param $subscription_id
1445 *
1446 * @throws WC_Vipps_Recurring_Exception
1447 */
1448 public function maybe_process_gateway_change( $subscription_id ): void {
1449 $subscription = wcs_get_subscription( $subscription_id );
1450
1451 if ( $subscription->meta_exists( '_new_agreement_id' ) ) {
1452 $new_agreement_id = WC_Vipps_Recurring_Helper::get_meta( $subscription, '_new_agreement_id' );
1453
1454 try {
1455 $agreement = $this->api->get_agreement( $new_agreement_id );
1456 } catch ( WC_Vipps_Recurring_Exception $e ) {
1457 if ( $e->response_code === 404 ) {
1458 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Agreement %s not found (404) during gateway change check, ending gateway change checking', WC_Vipps_Recurring_Helper::get_id( $subscription ), $new_agreement_id ) );
1459
1460 $this->end_gateway_change_checking( $subscription );
1461 $subscription->save();
1462
1463 return;
1464 }
1465
1466 throw $e;
1467 }
1468
1469 if ( $agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE ) {
1470 $old_agreement_id = WC_Vipps_Recurring_Helper::get_meta( $subscription, '_old_agreement_id' );
1471
1472 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Processing subscription gateway change with new agreement id: %s and old agreement id: %s', WC_Vipps_Recurring_Helper::get_id( $subscription ), $new_agreement_id, $old_agreement_id ) );
1473
1474 if ( ! empty( $old_agreement_id ) && $new_agreement_id !== $old_agreement_id ) {
1475 try {
1476 $charges = $this->api->get_charges_for( $old_agreement_id );
1477 $pending_charges = array_filter( $charges, function ( WC_Vipps_Charge $charge ) {
1478 return in_array( $charge->status, [
1479 WC_Vipps_Charge::STATUS_PENDING,
1480 WC_Vipps_Charge::STATUS_RESERVED,
1481 WC_Vipps_Charge::STATUS_DUE
1482 ] );
1483 } );
1484
1485 // We should not under any circumstances cancel agreements that still have pending charges when changing gateways.
1486 if ( empty( $pending_charges ) ) {
1487 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Cancelling old agreement id: %s in Vipps/MobilePay due to gateway change', WC_Vipps_Recurring_Helper::get_id( $subscription ), $old_agreement_id ) );
1488
1489 $idempotency_key = $this->get_idempotency_key( $subscription );
1490 $this->api->cancel_agreement( $old_agreement_id, $idempotency_key );
1491 }
1492 } catch ( WC_Vipps_Recurring_Exception $cleanup_e ) {
1493 // If the old agreement is already gone there's nothing to clean up — proceed with the gateway change.
1494 if ( $cleanup_e->response_code !== 404 ) {
1495 throw $cleanup_e;
1496 }
1497
1498 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Old agreement %s no longer exists in Vipps/MobilePay (404), skipping cleanup', WC_Vipps_Recurring_Helper::get_id( $subscription ), $old_agreement_id ) );
1499 }
1500 }
1501
1502 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, $new_agreement_id );
1503
1504 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Subscription gateway change completed', WC_Vipps_Recurring_Helper::get_id( $subscription ) ) );
1505 WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $subscription, $this->id );
1506 $this->end_gateway_change_checking( $subscription );
1507 }
1508
1509 if ( in_array( $agreement->status, [ WC_Vipps_Agreement::STATUS_STOPPED, 'EXPIRED' ], true ) ) {
1510 $subscription->add_order_note( __( 'Payment gateway change request cancelled in Vipps/MobilePay', 'woo-vipps' ) );
1511 }
1512
1513 if ( in_array( $agreement->status, [
1514 WC_Vipps_Agreement::STATUS_STOPPED,
1515 WC_Vipps_Agreement::STATUS_EXPIRED
1516 ], true ) ) {
1517 $this->end_gateway_change_checking( $subscription );
1518 }
1519
1520 $subscription->save();
1521 }
1522 }
1523
1524 /**
1525 * @param $subscription_id
1526 *
1527 * @throws WC_Vipps_Recurring_Config_Exception
1528 * @throws WC_Vipps_Recurring_Exception
1529 * @throws WC_Vipps_Recurring_Temporary_Exception
1530 */
1531 public function maybe_update_subscription_details_in_app( $subscription_id ): void {
1532 $subscription = wcs_get_subscription( $subscription_id );
1533
1534 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $subscription );
1535 if ( $payment_method !== $this->id ) {
1536 return;
1537 }
1538
1539 $agreement = $this->get_agreement_from_order( $subscription );
1540 if ( $agreement !== false && $agreement->status !== WC_Vipps_Agreement::STATUS_ACTIVE ) {
1541 WC_Vipps_Recurring_Helper::set_update_in_app_completed( $subscription );
1542
1543 return;
1544 }
1545
1546 $parent_order = $subscription->get_parent();
1547 $items = array_reverse( $subscription->get_items() );
1548
1549 // we can only ever have one subscription as long as 'multiple_subscriptions' is disabled
1550 $item = array_pop( $items );
1551
1552 if ( ! $item ) {
1553 return;
1554 }
1555
1556 $item_name = $item->get_name();
1557 $parent_product = wc_get_product( $item->get_product_id() );
1558 $product_description = WC_Vipps_Recurring_Helper::get_product_description( $parent_product );
1559
1560 $agreement_description = null;
1561 if ( $prefix = WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX ) ) {
1562 $agreement_description = "[$prefix]";
1563 }
1564
1565 if ( $product_description ) {
1566 $agreement_description .= " $product_description";
1567 }
1568
1569 $updated_agreement = ( new WC_Vipps_Agreement() )
1570 ->set_pricing( ( new WC_Vipps_Agreement_Pricing() )
1571 ->set_amount( WC_Vipps_Recurring_Helper::get_vipps_amount( $subscription->get_total() ) ) )
1572 ->set_product_name( $item_name );
1573
1574 if ( $agreement_description ) {
1575 $updated_agreement = $updated_agreement->set_product_description( $agreement_description );
1576 }
1577
1578 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $subscription );
1579 if ( empty( $agreement_id ) ) {
1580 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $parent_order );
1581 }
1582
1583 if ( $agreement_id ) {
1584 try {
1585 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Agreement updated in app for agreement id: %s', $subscription_id, $agreement_id ) );
1586
1587 $idempotency_key = $this->get_idempotency_key( $subscription );
1588 $this->api->update_agreement( $agreement_id, $updated_agreement, $idempotency_key );
1589 } catch ( Exception $e ) {
1590 // do nothing
1591 }
1592 }
1593
1594 WC_Vipps_Recurring_Helper::set_update_in_app_completed( $subscription );
1595 }
1596
1597 /**
1598 * @param $order
1599 * @param $subscription
1600 * @param bool $is_gateway_change
1601 *
1602 * @return WC_Vipps_Agreement
1603 * @throws WC_Vipps_Recurring_Invalid_Value_Exception
1604 */
1605 public function create_vipps_agreement_from_order( $order, $subscription = null, bool $is_gateway_change = false, bool $is_failed_renewal_order = false ): WC_Vipps_Agreement {
1606 $order_id = WC_Vipps_Recurring_Helper::get_id( $order );
1607
1608 if ( ! $subscription ) {
1609 $subscription = $order;
1610 }
1611
1612 $subscription_id = WC_Vipps_Recurring_Helper::get_id( $subscription );
1613
1614 // This supports not yet having a subscription, purely because of Express and Checkout orders
1615 if ( is_a( $subscription, 'WC_Subscription' ) ) {
1616 $subscription_period = $subscription->get_billing_period();
1617 $subscription_interval = $subscription->get_billing_interval();
1618 } else {
1619 $subscription_groups = $this->create_partial_subscription_groups_from_order( $order );
1620 $items = array_pop( $subscription_groups );
1621 $product = $items[0]->get_product();
1622
1623 $subscription_period = WC_Subscriptions_Product::get_period( $product );
1624 $subscription_interval = WC_Subscriptions_Product::get_interval( $product );
1625 }
1626
1627 $items = array_reverse( $order->get_items() );
1628
1629 $has_more_products = count( $items ) > 1;
1630
1631 // we can only ever have one subscription as long as 'multiple_subscriptions' is disabled, so we can fetch the first subscription
1632 $subscription_items = array_filter( $items, static function ( $item ) {
1633 return apply_filters( 'wc_vipps_recurring_item_is_subscription', WC_Subscriptions_Product::is_subscription( $item['product_id'] ), $item );
1634 } );
1635
1636 $subscription_item = array_pop( $subscription_items );
1637 $product = $subscription_item->get_product();
1638 $parent_product = wc_get_product( $subscription_item->get_product_id() );
1639
1640 $extra_initial_charge_description = '';
1641
1642 if ( $has_more_products ) {
1643 $other_items = array_filter( $items, static function ( $other_item ) use ( $subscription_item ) {
1644 return $subscription_item['product_id'] !== $other_item['product_id'];
1645 } );
1646
1647 foreach ( $other_items as $product_item ) {
1648 $extra_initial_charge_description .= WC_Vipps_Recurring_Helper::get_product_description( $product_item->get_product() ) . ', ';
1649 }
1650
1651 $extra_initial_charge_description = rtrim( $extra_initial_charge_description, ', ' );
1652 }
1653
1654 $is_virtual = $product->is_virtual();
1655 $direct_capture = $parent_product->get_meta( WC_Vipps_Recurring_Helper::META_PRODUCT_DIRECT_CAPTURE ) === 'yes';
1656
1657 $agreement_url = filter_var( get_permalink( get_option( 'woocommerce_myaccount_page_id' ) ), FILTER_VALIDATE_URL )
1658 ? get_permalink( get_option( 'woocommerce_myaccount_page_id' ) )
1659 : wc_get_account_endpoint_url( 'dashboard' );
1660
1661 if ( ! filter_var( $agreement_url, FILTER_VALIDATE_URL ) ) {
1662 $agreement_url = get_home_url();
1663 }
1664
1665 $redirect_url = WC_Vipps_Recurring_Helper::get_payment_redirect_url( $order, $is_gateway_change );
1666
1667 // total no longer returns the order amount when gateway is being changed
1668 $agreement_total = $subscription->get_total( 'code' );
1669
1670 // when we're performing a variation switch we need some special logic in Vipps
1671 $is_subscription_switch = wcs_order_contains_switch( $order );
1672
1673 if ( $is_subscription_switch ) {
1674 $subscription_switch_data = WC_Vipps_Recurring_Helper::get_meta( $order, '_subscription_switch_data' );
1675
1676 if ( isset( $subscription_switch_data[ array_key_first( $subscription_switch_data ) ]['switches'] ) ) {
1677 $switches = $subscription_switch_data[ array_key_first( $subscription_switch_data ) ]['switches'];
1678 $switch_data = $switches[ array_key_first( $switches ) ];
1679 $direction = $switch_data['switch_direction'];
1680
1681 if ( $direction === 'upgrade' ) {
1682 $agreement_total += $order->get_total();
1683 }
1684 }
1685 }
1686
1687 $has_trial = WC_Subscriptions_Product::get_trial_length( $product ) !== 0;
1688 $is_zero_amount = (int) $order->get_total() === 0 || $is_gateway_change;
1689 $capture_immediately = $is_virtual || $direct_capture || $is_gateway_change || $is_failed_renewal_order;
1690 $has_synced_product = WC_Subscriptions_Synchroniser::subscription_contains_synced_product( $subscription );
1691
1692 $sign_up_fee = WC_Subscriptions_Order::get_sign_up_fee( $order );
1693 $has_campaign = $has_trial || $has_synced_product || $is_zero_amount || $order->get_total_discount() !== 0.00 || $is_subscription_switch || $sign_up_fee;
1694 $has_free_campaign = $is_subscription_switch || $sign_up_fee || $has_synced_product || $has_trial;
1695
1696 // when Prorate First Renewal is set to "Never (charge the full recurring amount at sign-up)" we don't want to have a campaign
1697 // also not when the order total is the same as the agreement total
1698 if ( $has_free_campaign && $has_synced_product && $order->get_total() === $agreement_total ) {
1699 $has_campaign = false;
1700 }
1701
1702 $agreement = ( new WC_Vipps_Agreement() )
1703 ->set_external_id( $subscription_id )
1704 ->set_pricing(
1705 ( new WC_Vipps_Agreement_Pricing() )
1706 ->set_type( WC_Vipps_Agreement_Pricing::TYPE_LEGACY )
1707 ->set_currency( $order->get_currency() )
1708 ->set_amount( apply_filters( 'wc_vipps_recurring_agreement_pricing_amount', WC_Vipps_Recurring_Helper::get_vipps_amount( $agreement_total ), $order ) )
1709 )
1710 ->set_interval(
1711 ( new WC_Vipps_Agreement_Interval() )
1712 ->set_unit( strtoupper( $subscription_period ) )
1713 ->set_count( (int) $subscription_interval )
1714 )
1715 ->set_product_name( $subscription_item->get_name() )
1716 ->set_merchant_agreement_url( apply_filters( 'wc_vipps_recurring_merchant_agreement_url', $agreement_url ) )
1717 ->set_merchant_redirect_url( apply_filters( 'wc_vipps_recurring_merchant_redirect_url', $redirect_url ) );
1718
1719 $product_description = WC_Vipps_Recurring_Helper::get_product_description( $product );
1720 if ( $product_description ) {
1721 $agreement = $agreement->set_product_description( $product_description );
1722 }
1723
1724 // validate phone number and only add it if it's up to Vipps' standard to avoid errors
1725 if ( WC_Vipps_Recurring_Helper::is_valid_phone_number( $order->get_billing_phone() ) ) {
1726 $agreement = $agreement->set_phone_number( $order->get_billing_phone() );
1727 }
1728
1729 if ( ! $is_zero_amount ) {
1730 $initial_charge_description = WC_Vipps_Recurring_Helper::get_product_description( $parent_product );
1731 if ( ! empty( $extra_initial_charge_description ) ) {
1732 $initial_charge_description .= ' + ' . $extra_initial_charge_description;
1733
1734 if ( $has_campaign ) {
1735 $initial_charge_description = $extra_initial_charge_description;
1736 }
1737 }
1738
1739 $vipps_order_id = WC_Vipps_Recurring_Helper::generate_vipps_order_id( $order, $this->order_prefix );
1740
1741 $agreement = $agreement->set_initial_charge(
1742 ( new WC_Vipps_Agreement_Initial_Charge() )
1743 ->set_amount( apply_filters( 'wc_vipps_recurring_agreement_initial_charge_amount', WC_Vipps_Recurring_Helper::get_vipps_amount( $order->get_total() ), $order ) )
1744 ->set_description( empty( $initial_charge_description ) ? $subscription_item->get_name() : $initial_charge_description )
1745 ->set_transaction_type( $capture_immediately ? WC_Vipps_Agreement_Initial_Charge::TRANSACTION_TYPE_DIRECT_CAPTURE : WC_Vipps_Agreement_Initial_Charge::TRANSACTION_TYPE_RESERVE_CAPTURE )
1746 ->set_order_id( $vipps_order_id )
1747 ->set_external_id( $order_id )
1748 );
1749
1750 WC_Vipps_Recurring_Helper::update_meta_data( $order, $capture_immediately ? WC_Vipps_Recurring_Helper::META_ORDER_DIRECT_CAPTURE : WC_Vipps_Recurring_Helper::META_ORDER_RESERVED_CAPTURE, true );
1751 }
1752
1753 if ( $has_campaign ) {
1754 $campaign_price = $has_free_campaign ? 0 : $order->get_total();
1755
1756 $campaign_type = WC_Vipps_Agreement_Campaign::TYPE_PRICE_CAMPAIGN;
1757 $campaign_period = null;
1758
1759 if ( $has_trial ) {
1760 $campaign_type = WC_Vipps_Agreement_Campaign::TYPE_PERIOD_CAMPAIGN;
1761 $campaign_end_date = null;
1762 $campaign_period = ( new WC_Vipps_Agreement_Campaign_Period() )
1763 ->set_count( WC_Subscriptions_Product::get_trial_length( $product ) )
1764 ->set_unit( strtoupper( WC_Subscriptions_Product::get_trial_period( $product ) ) );
1765 } else {
1766 $next_payment = WC_Subscriptions_Product::get_first_renewal_payment_date( $product );
1767 $end_date = WC_Subscriptions_Product::get_expiration_date( $product );
1768
1769 $campaign_end_date = $end_date === 0 ? $next_payment : $end_date;
1770 }
1771
1772 $agreement = $agreement->set_campaign(
1773 ( new WC_Vipps_Agreement_Campaign() )
1774 ->set_type( $campaign_type )
1775 ->set_price( WC_Vipps_Recurring_Helper::get_vipps_amount( $campaign_price ) )
1776 ->set_end( $campaign_end_date )
1777 ->set_period( $campaign_period )
1778 );
1779 }
1780
1781 $order->save();
1782
1783 return apply_filters( 'wc_vipps_recurring_process_payment_agreement', $agreement, $subscription, $order );
1784 }
1785
1786 /**
1787 * Process a payment when checking out in WooCommerce
1788 *
1789 * https://docs.woocommerce.com/document/subscriptions/develop/payment-gateway-integration/
1790 * "Putting it all Together"
1791 *
1792 * @param int $order_id
1793 *
1794 * @throws Exception
1795 */
1796 public function process_payment( $order_id, bool $retry = true, bool $previous_error = false ): array {
1797 $is_gateway_change = wcs_is_subscription( $order_id );
1798
1799 $subscription = null;
1800
1801 $order = wc_get_order( $order_id );
1802 $debug_msg = sprintf( '[%s] process_payment (gateway change: %s)', $order_id, $is_gateway_change ? 'Yes' : 'No' ) . "\n";
1803
1804 $is_failed_renewal_order = wcs_cart_contains_failed_renewal_order_payment() !== false;
1805 $contains_manual_subscription = wcs_order_contains_manual_subscription( $order );
1806
1807 if ( ! $is_gateway_change
1808 && ! $is_failed_renewal_order
1809 && ! $contains_manual_subscription
1810 && ! wcs_order_contains_subscription( $order )
1811 && ! wcs_order_contains_early_renewal( $order ) ) {
1812 return [
1813 'result' => 'fail',
1814 'redirect' => ''
1815 ];
1816 }
1817
1818 // If we have an early renewal order on our hands we should deal with it accordingly
1819 if ( wcs_order_contains_early_renewal( $order ) ) {
1820 $renewal_order = $order;
1821
1822 $subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order );
1823 $subscription = $subscriptions[ array_key_first( $subscriptions ) ];
1824
1825 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $subscription );
1826 if ( $agreement_id ) {
1827 $existing_agreement = $this->api->get_agreement( $agreement_id );
1828
1829 if ( $existing_agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE ) {
1830 WC_Subscriptions_Payment_Gateways::trigger_gateway_renewal_payment_hook( $renewal_order );
1831
1832 // Trigger the subscription payment complete hooks and reset suspension counts and user roles.
1833 $subscription->payment_complete();
1834
1835 wcs_update_dates_after_early_renewal( $subscription, $renewal_order );
1836 wc_add_notice( __( 'Your early renewal order was successful.', 'woocommerce-subscriptions' ) );
1837
1838 $renewal_order = wc_get_order( $renewal_order->get_id() );
1839
1840 return [
1841 'result' => 'success',
1842 'redirect' => $renewal_order->get_view_order_url()
1843 ];
1844 }
1845 }
1846
1847 // We need to update the gateway to Vipps/MobilePay after the payment is completed if an agreement is active
1848 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_RENEWING_WITH_VIPPS, true );
1849 $subscription->save();
1850 }
1851
1852 try {
1853 if ( ! $subscription ) {
1854 $subscription = $order;
1855 }
1856
1857 if ( ! $is_gateway_change ) {
1858 $subscriptions = $this->get_subscriptions_for_order( $order );
1859
1860 // we can only ever have one subscription as long as 'multiple_subscriptions' is disabled
1861 $subscription = $subscriptions[ array_key_first( $subscriptions ) ];
1862 }
1863
1864 /*
1865 * if this order has a PENDING or ACTIVE agreement in Vipps/MobilePay we should not allow checkout anymore
1866 * this will prevent duplicate transactions
1867 */
1868 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $order );
1869 $already_swapping_to_vipps = WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_WAITING_FOR_GATEWAY_CHANGE );
1870
1871 if ( $agreement_id && ( ! $is_gateway_change || $already_swapping_to_vipps ) && ! $is_failed_renewal_order ) {
1872 if ( ! $already_swapping_to_vipps ) {
1873 $existing_agreement = $this->get_agreement_from_order( $order );
1874 } else {
1875 $new_agreement_id = WC_Vipps_Recurring_Helper::get_meta( $subscription, '_new_agreement_id' );
1876 $existing_agreement = $this->api->get_agreement( $new_agreement_id );
1877 }
1878
1879 if ( $existing_agreement->status === WC_Vipps_Agreement::STATUS_PENDING ) {
1880 return [
1881 'result' => 'success',
1882 'redirect' => $existing_agreement->vipps_confirmation_url,
1883 ];
1884 }
1885
1886 if ( $existing_agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE ) {
1887 throw new WC_Vipps_Recurring_Temporary_Exception( __( 'This subscription is already active in Vipps/MobilePay. You can leave this page.', 'woo-vipps' ) );
1888 }
1889 }
1890
1891 $items = array_reverse( $order->get_items() );
1892
1893 /*
1894 * WooCommerce Subscriptions should really deal with this themselves, but when other gateways with support for `multiple_subscriptions`
1895 * are enabled WooCommerce Subscriptions allows this scenario for our plugin too. This is not ideal so let's deny it here.
1896 * There's no good way to make this obvious to a customer in the app, especially if the products have different durations
1897 * i.e. one yearly and one monthly
1898 */
1899 $has_more_products = count( $items ) > 1;
1900 if ( $has_more_products ) {
1901 $counter = 1;
1902
1903 foreach ( $items as $item ) {
1904 if ( ! WC_Subscriptions_Product::is_subscription( $item['product_id'] ) ) {
1905 continue;
1906 }
1907
1908 if ( $counter > 1 ) {
1909 // translators: %s: brand (Vipps or MobilePay)
1910 wc_add_notice( sprintf( __( 'Different subscription products can not be purchased at the same time using %s.', 'woo-vipps' ), $this->title ), 'error' );
1911
1912 return [
1913 'result' => 'fail',
1914 'redirect' => ''
1915 ];
1916 }
1917
1918 $counter ++;
1919 }
1920 }
1921
1922 $agreement = $this->create_vipps_agreement_from_order( $order, $subscription, $is_gateway_change, $is_failed_renewal_order );
1923
1924 $idempotency_key = $this->get_idempotency_key( $order );
1925 if ( $is_gateway_change ) {
1926 $idempotency_key = $this->api->generate_idempotency_key();
1927 }
1928
1929 $response = $this->api->create_agreement( $agreement, $idempotency_key );
1930
1931 $is_subscription_switch = wcs_order_contains_switch( $order );
1932 $is_zero_amount = (int) $order->get_total() === 0 || $is_gateway_change;
1933
1934 // mark the old agreement for cancellation to leave no dangling agreements in Vipps
1935 $should_cancel_old = $is_gateway_change || $is_subscription_switch || $is_failed_renewal_order;
1936 if ( $should_cancel_old ) {
1937 if ( $is_gateway_change ) {
1938 /* translators: Vipps/MobilePay Agreement ID */
1939 $message = sprintf( __( 'Request to change gateway to Vipps/MobilePay with agreement ID: %s.', 'woo-vipps' ), $response['agreementId'] );
1940 $subscription->add_order_note( $message );
1941 $debug_msg .= 'Request to change gateway to Vipps' . "\n";
1942 } elseif ( $is_subscription_switch ) {
1943 $debug_msg .= 'Request to switch subscription variation' . "\n";
1944 } else {
1945 $debug_msg .= 'Request to pay for a failed renewal order' . "\n";
1946 }
1947
1948 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, '_old_agreement_id', WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $subscription ) );
1949 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, '_new_agreement_id', $response['agreementId'] );
1950 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_WAITING_FOR_GATEWAY_CHANGE, true );
1951 }
1952
1953 if ( ! $is_gateway_change ) {
1954 if ( ! $should_cancel_old ) {
1955 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, $response['agreementId'] );
1956 }
1957
1958 if ( $is_zero_amount ) {
1959 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_ORDER_ZERO_AMOUNT, true );
1960 }
1961
1962 if ( $contains_manual_subscription ) {
1963 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, $response['agreementId'] );
1964 $subscription->set_payment_method( $this->id );
1965 $subscription->save();
1966 }
1967
1968 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, $response['agreementId'] );
1969 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING, true );
1970
1971 /* translators: Vipps/MobilePay Agreement ID */
1972 $message = sprintf( __( 'Agreement created: %s. Customer sent to Vipps/MobilePay for confirmation.', 'woo-vipps' ), $response['agreementId'] );
1973 $order->add_order_note( $message );
1974 }
1975
1976 $debug_msg .= sprintf( 'Created agreement with agreement ID: %s', $response['agreementId'] ) . "\n";
1977
1978 if ( isset( $response['chargeId'] ) ) {
1979 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_ID, $response['chargeId'] );
1980 }
1981
1982 WC_Vipps_Recurring_Helper::delete_meta_data( $order, WC_Vipps_Recurring_Helper::META_ORDER_IS_CHECKOUT );
1983 WC_Vipps_Recurring_Helper::delete_meta_data( $order, WC_Vipps_Recurring_Helper::META_ORDER_IS_EXPRESS );
1984 WC_Vipps_Recurring_Helper::delete_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_ORDER_IS_CHECKOUT );
1985 WC_Vipps_Recurring_Helper::delete_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_ORDER_IS_EXPRESS );
1986
1987 // save meta
1988 $order->save();
1989 $subscription->save();
1990
1991 $debug_msg .= sprintf( 'Debug body: %s', json_encode( $agreement->to_array() ) ) . "\n";
1992 $debug_msg .= sprintf( 'Debug response: %s', json_encode( array_merge( $response, [ 'vippsConfirmationUrl' => 'redacted' ] ) ) );
1993 WC_Vipps_Recurring_Logger::log( $debug_msg );
1994
1995 return [
1996 'result' => 'success',
1997 'redirect' => $response['vippsConfirmationUrl'],
1998 ];
1999 } catch ( WC_Vipps_Recurring_Temporary_Exception $e ) {
2000 wc_add_notice( $e->getMessage(), 'error' );
2001
2002 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Temporary error in process_payment: %s', $order_id, $e->getMessage() ) );
2003
2004 return [
2005 'result' => 'fail',
2006 'redirect' => '',
2007 ];
2008 } catch ( WC_Vipps_Recurring_Exception $e ) {
2009 wc_add_notice( $e->getLocalizedMessage(), 'error' );
2010
2011 $order->update_status( 'failed' );
2012
2013 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Error in process_payment: %s', $order_id, $e->getMessage() ) );
2014
2015 return [
2016 'result' => 'fail',
2017 'redirect' => '',
2018 ];
2019 }
2020 }
2021
2022 /**
2023 * All payment icons that work with Vipps. Some icons references
2024 * WC core icons.
2025 *
2026 * @return array
2027 * @since 4.1.0 Changed to using img with svg (colored) instead of fonts.
2028 * @since 4.0.0
2029 */
2030 public function payment_icons(): array {
2031 return apply_filters(
2032 'wc_vipps_recurring_payment_icons',
2033 [
2034 'vippsmobilepay' => '<img src="' . WC_VIPPS_RECURRING_PLUGIN_URL . '/assets/images/' . $this->brand . '-mark.svg" class="vipps-recurring-icon" alt="' . $this->title . '" />',
2035 ]
2036 );
2037 }
2038
2039 /**
2040 * Get_icon function.
2041 *
2042 * @return string
2043 * @version 4.0.0
2044 * @since 1.0.0
2045 */
2046 public function get_icon(): string {
2047 $icons = $this->payment_icons();
2048
2049 return apply_filters( 'woocommerce_gateway_icon', $icons['vippsmobilepay'], $this->id );
2050 }
2051
2052 /**
2053 * @param $key
2054 * @param $data
2055 *
2056 * @return false|string
2057 */
2058 public function generate_page_dropdown_html( $key, $data ) {
2059 $field_key = $this->get_field_key( $key );
2060 $defaults = [
2061 'title' => '',
2062 'class' => '',
2063 'type' => 'page_dropdown',
2064 'desc_tip' => false,
2065 'description' => '',
2066 'show_option_none' => ''
2067 ];
2068
2069 $data = wp_parse_args( $data, $defaults );
2070
2071 $dropdown_pages = wp_dropdown_pages( [
2072 'echo' => 0,
2073 'show_option_none' => $data['show_option_none'],
2074 'selected' => $this->get_option( $key ),
2075 'id' => $field_key,
2076 'name' => $field_key,
2077 'class' => $data['class']
2078 ] );
2079
2080 ob_start();
2081 ?>
2082 <tr valign="top">
2083 <th
2084 scope="row"
2085 class="titledesc"
2086 >
2087 <label
2088 for="<?php echo esc_attr( $field_key ); ?>"><?php echo wp_kses_post( $data['title'] ); ?><?php echo $this->get_tooltip_html( $data ); // WPCS: XSS ok. ?></label>
2089 </th>
2090 <td class="forminp">
2091 <fieldset>
2092 <legend class="screen-reader-text"><span><?php echo wp_kses_post( $data['title'] ); ?></span>
2093 </legend>
2094 <?php echo $dropdown_pages; ?>
2095 <?php echo $this->get_description_html( $data ); // WPCS: XSS ok. ?>
2096 </fieldset>
2097 </td>
2098 </tr>
2099 <?php
2100
2101 return ob_get_clean();
2102 }
2103
2104 /**
2105 * Initialise Gateway Settings Form Fields
2106 */
2107 public function init_form_fields(): void {
2108 $this->form_fields = require( __DIR__ . '/admin/vipps-recurring-settings.php' );
2109
2110 // Set some options and default values
2111 $this->form_fields['brand']['default'] = $this->detect_default_brand();
2112
2113 $this->form_fields['order_prefix']['default'] = WC_Vipps_Recurring::get_instance()->generate_order_prefix();
2114
2115 // Hide checkout unless it is already enabled.
2116 // todo: remove the checkout settings in a future release.
2117 if ( $this->get_option( 'checkout_enabled' ) !== "yes" ) {
2118 foreach ( array_keys( $this->form_fields ) as $key ) {
2119 if ( $key === 'title_checkout' || strpos( $key, 'checkout_' ) === 0 ) {
2120 unset( $this->form_fields[ $key ] );
2121 }
2122 }
2123 }
2124
2125 if ( $this->get_option( 'test_mode' ) === "yes" || WC_VIPPS_RECURRING_TEST_MODE ) {
2126 $this->form_fields['title_test_api'] = [
2127 'type' => 'title',
2128 'title' => __( 'Test API settings', 'woo-vipps' ),
2129 ];
2130
2131 $this->form_fields['test_merchant_serial_number'] = $this->form_fields['merchant_serial_number'];
2132 $this->form_fields['test_client_id'] = $this->form_fields['client_id'];
2133 $this->form_fields['test_secret_key'] = $this->form_fields['secret_key'];
2134 $this->form_fields['test_subscription_key'] = $this->form_fields['subscription_key'];
2135 }
2136 }
2137
2138 function validate_text_field( $key, $value ) {
2139 if ( $key !== 'order_prefix' ) {
2140 return parent::validate_text_field( $key, $value );
2141 }
2142
2143 return preg_replace( '![^a-zA-Z0-9\-]!', '', $value );
2144 }
2145
2146 public function detect_default_brand(): string {
2147 $locale = get_locale();
2148 $store_location = wc_get_base_location();
2149 $store_country = $store_location['country'] ?? '';
2150 $currency = get_woocommerce_currency();
2151
2152 $default_brand = "mobilepay";
2153
2154 // If store location, locale, or currency is Norwegian, use Vipps
2155 if ( $store_country == "NO" || preg_match( "/_NO/", $locale ) || $currency == "NOK" ) {
2156 $default_brand = "vipps";
2157 }
2158
2159 return $default_brand;
2160 }
2161
2162 /**
2163 * @param $what
2164 */
2165 private function admin_error( $what ): void {
2166 add_action( 'admin_notices', static function () use ( $what ) {
2167 echo "<div class='notice notice-error is-dismissible'><p>$what</p></div>";
2168 } );
2169 }
2170
2171 /**
2172 * @param $what
2173 */
2174 private function admin_notify( $what, $type = "info" ): void {
2175 add_action( 'admin_notices', static function () use ( $what, $type ) {
2176 echo "<div class='notice notice-$type is-dismissible'><p>$what</p></div>";
2177 } );
2178 }
2179
2180 public function process_admin_options(): bool {
2181 $saved = parent::process_admin_options();
2182 delete_transient( '_vipps_keyset' ); // Same transient as for the payment api IOK 2024-12-03
2183
2184 $this->init_form_fields();
2185
2186 if ( $this->get_option( 'enabled' ) === "yes" ) {
2187 $this->webhook_initialize();
2188
2189 try {
2190 $this->api->get_access_token( true );
2191 update_option( WC_Vipps_Recurring_Helper::OPTION_CONFIGURED, 1, true );
2192
2193 $this->admin_notify( __( 'Successfully authenticated with the Vipps/MobilePay API', 'woo-vipps' ) );
2194 } catch ( Exception $e ) {
2195 update_option( WC_Vipps_Recurring_Helper::OPTION_CONFIGURED, 0, true );
2196
2197 /* translators: %s: the error message returned from Vipps/MobilePay */
2198 $this->admin_error( sprintf( __( 'Could not authenticate with the Vipps/MobilePay API: %s', 'woo-vipps' ), $e->getMessage() ) );
2199 }
2200 }
2201
2202 $checkout_enabled = $this->get_option( 'checkout_enabled' ) === "yes";
2203 $test_mode = $this->get_option( 'test_mode' ) === "yes" || WC_VIPPS_RECURRING_TEST_MODE;
2204
2205 // Validate that we have an MSN set, as it is required in Checkout.
2206 $merchant_serial_number = $test_mode ? $this->get_option( 'test_merchant_serial_number' ) : $this->get_option( 'merchant_serial_number' );
2207 if ( empty( $merchant_serial_number ) ) {
2208 $this->admin_notify( __( 'You need to provide a Merchant Serial Number before you can enable Checkout.', 'woo-vipps' ), "error" );
2209
2210 // Disable checkout if we do not have an MSN value.
2211 $this->update_option( 'checkout_enabled', 'no' );
2212 update_option( WC_Vipps_Recurring_Helper::OPTION_CHECKOUT_ENABLED, false, true );
2213
2214 return $saved;
2215 }
2216
2217 update_option( WC_Vipps_Recurring_Helper::OPTION_CHECKOUT_ENABLED, $checkout_enabled, true );
2218
2219 if ( $checkout_enabled ) {
2220 WC_Vipps_Recurring::get_instance()->maybe_create_checkout_page();
2221 }
2222
2223 return $saved;
2224 }
2225
2226 /**
2227 * @param $statuses
2228 *
2229 * @return array
2230 */
2231 public function append_valid_statuses_for_payment_complete( $statuses ): array {
2232 $statuses = array_merge( $statuses, $this->statuses_to_attempt_capture );
2233
2234 if ( $this->transition_renewals_to_completed && ! in_array( 'completed', $statuses, true ) ) {
2235 $statuses[] = 'completed';
2236 }
2237
2238 return $statuses;
2239 }
2240
2241 /**
2242 * @param $payment_meta
2243 * @param $subscription
2244 *
2245 * @return mixed
2246 */
2247 public function add_subscription_payment_meta( $payment_meta, $subscription ) {
2248 $payment_meta[ $this->id ] = [
2249 'post_meta' => [
2250 '_agreement_id' => [
2251 'value' => WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID ),
2252 'label' => __( 'Vipps/MobilePay Agreement ID', 'woo-vipps' ),
2253 ]
2254 ],
2255 ];
2256
2257 return $payment_meta;
2258 }
2259
2260 /**
2261 * @param $payment_method_id
2262 * @param $payment_meta
2263 *
2264 * @throws Exception
2265 */
2266 public function validate_subscription_payment_meta( $payment_method_id, $payment_meta ): void {
2267 if ( $this->id !== $payment_method_id ) {
2268 return;
2269 }
2270
2271 $agreement_id = $payment_meta['post_meta']['_agreement_id']['value'];
2272
2273 try {
2274 $this->api->get_agreement( $agreement_id );
2275 } catch ( Exception $e ) {
2276 throw new \RuntimeException( __( 'This Vipps/MobilePay agreement ID is invalid.', 'woo-vipps' ) );
2277 }
2278 }
2279
2280 public function cart_needs_payment( $needs_payment, $cart ) {
2281 $cart_switch_items = wcs_cart_contains_switches();
2282
2283 if ( false === $needs_payment && 0 == $cart->total && false !== $cart_switch_items && ! wcs_is_manual_renewal_required() ) {
2284 foreach ( $cart_switch_items as $cart_switch_details ) {
2285 $subscription = wcs_get_subscription( $cart_switch_details['subscription_id'] );
2286
2287 if ( $this->id === $subscription->get_payment_method() ) {
2288 $needs_payment = true;
2289 break;
2290 }
2291 }
2292 }
2293
2294 return $needs_payment;
2295 }
2296
2297 /**
2298 * @param $subscription
2299 */
2300 public function handle_subscription_switch_completed( $subscription ): void {
2301 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $subscription );
2302 if ( $this->id !== $payment_method ) {
2303 return;
2304 }
2305
2306 WC_Vipps_Recurring_Logger::log( sprintf( "[%s] Subscription switch completed", WC_Vipps_Recurring_Helper::get_id( $subscription ) ) );
2307
2308 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP, 1 );
2309 WC_Vipps_Recurring_Helper::delete_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX );
2310 $subscription->save();
2311
2312 try {
2313 $this->maybe_update_subscription_details_in_app( WC_Vipps_Recurring_Helper::get_id( $subscription ) );
2314 } catch ( Exception $exception ) {
2315 // do nothing, we don't want to throw an error in the user's face
2316 }
2317 }
2318
2319 /**
2320 * @param WC_Subscription $subscription
2321 * @param $new_status
2322 * @param $old_status
2323 */
2324 public function maybe_handle_subscription_status_transitions( WC_Subscription $subscription, $new_status, $old_status ): void {
2325 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $subscription );
2326
2327 /*
2328 * note: if we reverse the if statement to reduce nesting I fear we may run into an early stop of code execution issue
2329 */
2330 if ( $this->id === $payment_method ) {
2331 $order = wc_get_order( WC_Vipps_Recurring_Helper::get_id( $subscription ) );
2332
2333 if ( $new_status === 'pending-cancel' ) {
2334 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP, 1 );
2335 WC_Vipps_Recurring_Helper::update_meta_data(
2336 $order,
2337 WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX,
2338 __( 'Pending cancellation', 'woo-vipps' )
2339 );
2340
2341 $order->save();
2342 }
2343
2344 if ( $new_status === 'cancelled' ) {
2345 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP, 1 );
2346 WC_Vipps_Recurring_Helper::update_meta_data(
2347 $order,
2348 WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX,
2349 __( 'Cancelled', 'woo-vipps' )
2350 );
2351
2352 $order->save();
2353 }
2354
2355 if ( $new_status === 'on-hold' ) {
2356 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP, 1 );
2357 WC_Vipps_Recurring_Helper::update_meta_data(
2358 $order,
2359 WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX,
2360 __( 'On hold', 'woo-vipps' )
2361 );
2362
2363 $order->save();
2364 }
2365
2366 if ( ( $old_status === 'pending-cancel' || $old_status === 'on-hold' ) && $new_status !== 'cancelled' ) {
2367 WC_Vipps_Recurring_Helper::update_meta_data( $order, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP, 1 );
2368 $order->delete_meta_data( WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX );
2369
2370 $order->save();
2371 }
2372 }
2373 }
2374
2375 /**
2376 * Don't transfer Vipps/MobilePay idempotency key or any other keys unique to a charge or order to resubscribe orders.
2377 *
2378 * @param mixed $resubscribe_order The order created for the customer to resubscribe to the old expired/cancelled subscription
2379 */
2380 public function delete_resubscribe_meta( $resubscribe_order ): void {
2381 WC_Vipps_Recurring_Helper::delete_meta_data( $resubscribe_order, WC_Vipps_Recurring_Helper::META_CHARGE_ID );
2382 WC_Vipps_Recurring_Helper::delete_meta_data( $resubscribe_order, WC_Vipps_Recurring_Helper::META_CHARGE_CAPTURED );
2383
2384 WC_Vipps_Recurring_Helper::delete_meta_data( $resubscribe_order, WC_Vipps_Recurring_Helper::META_ORDER_INITIAL );
2385 WC_Vipps_Recurring_Helper::delete_meta_data( $resubscribe_order, WC_Vipps_Recurring_Helper::META_ORDER_IS_EXPRESS );
2386 WC_Vipps_Recurring_Helper::delete_meta_data( $resubscribe_order, WC_Vipps_Recurring_Helper::META_ORDER_EXPRESS_AUTH_TOKEN );
2387
2388 $this->delete_renewal_meta( $resubscribe_order );
2389 }
2390
2391 /**
2392 * Don't transfer Vipps/MobilePay idempotency key or any other keys unique to a charge or order to renewal orders.
2393 *
2394 * @param mixed $renewal_order The renewal order
2395 */
2396 public function delete_renewal_meta( $renewal_order ) {
2397 if ( ! $renewal_order instanceof WC_Order ) {
2398 return $renewal_order;
2399 }
2400
2401 // Do not delete the idempotency key if the order has failed previously
2402 $has_failed_previously = WC_Vipps_Recurring_Helper::get_meta( $renewal_order, '_failed_renewal_order' );
2403 if ( $has_failed_previously !== "yes" ) {
2404 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_ORDER_IDEMPOTENCY_KEY );
2405 }
2406
2407 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_CHARGE_FAILED );
2408 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_CHARGE_FAILED_DESCRIPTION );
2409 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_CHARGE_FAILED_REASON );
2410 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_CHARGE_LATEST_STATUS );
2411 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP );
2412 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP_DESCRIPTION_PREFIX );
2413 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_CHARGE_CAPTURED );
2414
2415 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_ORDER_INITIAL );
2416 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_ORDER_IS_EXPRESS );
2417 WC_Vipps_Recurring_Helper::delete_meta_data( $renewal_order, WC_Vipps_Recurring_Helper::META_ORDER_EXPRESS_AUTH_TOKEN );
2418
2419 $renewal_order->save();
2420
2421 return $renewal_order;
2422 }
2423
2424 /**
2425 * @param $order_id
2426 */
2427 public function maybe_cancel_due_charge( $order_id ): void {
2428 $order = wc_get_order( $order_id );
2429
2430 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $order );
2431 if ( $this->id !== $payment_method ) {
2432 return;
2433 }
2434
2435 $agreement_id = WC_Vipps_Recurring_Helper::get_agreement_id_from_order( $order );
2436 $charge_id = WC_Vipps_Recurring_Helper::get_charge_id_from_order( $order );
2437
2438 if ( $agreement_id === null || $charge_id === null ) {
2439 WC_Vipps_Recurring_Helper::delete_meta_data( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING );
2440
2441 return;
2442 }
2443
2444 $pending_charge = WC_Vipps_Recurring_Helper::get_meta( $order, WC_Vipps_Recurring_Helper::META_CHARGE_PENDING );
2445
2446 if ( $pending_charge ) {
2447 try {
2448 if ( get_transient( 'maybe_cancel_due_charge_lock' . $order_id ) ) {
2449 return;
2450 }
2451
2452 set_transient( 'maybe_cancel_due_charge_lock' . $order_id, uniqid( '', true ), 30 );
2453
2454 $charge = $this->api->get_charge( $agreement_id, $charge_id );
2455 if ( in_array( $charge->status, [
2456 WC_Vipps_Charge::STATUS_DUE,
2457 WC_Vipps_Charge::STATUS_PENDING
2458 ], true ) ) {
2459 $idempotency_key = $this->get_idempotency_key( $order );
2460 $this->api->cancel_charge( $agreement_id, $charge_id, $idempotency_key );
2461 $order->add_order_note( __( 'Cancelled due charge in Vipps/MobilePay.', 'woo-vipps' ) );
2462 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Cancelled DUE charge with ID: %s for agreement with ID: %s', $order_id, $charge_id, $agreement_id ) );
2463 }
2464 } catch ( Exception $e ) {
2465 $order->add_order_note( __( 'Could not cancel charge in Vipps/MobilePay. Please manually check the status of this order if you plan to process a new renewal order!', 'woo-vipps' ) );
2466 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Failed cancelling DUE charge with ID: %s for agreement with ID: %s. Error: %s', $order_id, $charge_id, $agreement_id, $e->getMessage() ) );
2467 }
2468 }
2469 }
2470
2471 /**
2472 * When renewing early from a different gateway WooCommerce does not update the gateway for you.
2473 * If we detect that you've renewed early with Vipps/MobilePay and the gateway is not set to Vipps/MobilePay we will
2474 * update the gateway for you.
2475 *
2476 * @param $order_id
2477 *
2478 * @return void
2479 * @throws WC_Vipps_Recurring_Exception
2480 */
2481 public function after_renew_early_from_another_gateway( $order_id ): void {
2482 if ( ! wcs_order_contains_early_renewal( $order_id ) ) {
2483 return;
2484 }
2485
2486 $subscriptions = $this->get_subscriptions_for_order( $order_id );
2487
2488 foreach ( $subscriptions as $subscription ) {
2489 if ( $subscription->get_payment_method() === $this->id ) {
2490 continue;
2491 }
2492
2493 if ( ! WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_RENEWING_WITH_VIPPS ) ) {
2494 continue;
2495 }
2496
2497 $agreement = $this->get_agreement_from_order( $subscription );
2498 if ( ! $agreement || $agreement->status !== WC_Vipps_Agreement::STATUS_ACTIVE ) {
2499 continue;
2500 }
2501
2502 $subscription->set_payment_method( $this->id );
2503 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_RENEWING_WITH_VIPPS, false );
2504 $subscription->save();
2505 }
2506 }
2507
2508 /**
2509 * Prevent an order from being transitioned to "processing" when the status is already "completed"
2510 *
2511 * @param $status
2512 * @param $order_id
2513 * @param $order
2514 *
2515 * @return string|null
2516 */
2517 public function prevent_backwards_transition_on_completed_order( $status, $order_id, $order ): ?string {
2518 if ( $status === 'processing'
2519 && $order->has_status( 'completed' )
2520 && $order->get_payment_method() === $this->id ) {
2521 return 'completed';
2522 }
2523
2524 return $status;
2525 }
2526
2527 public function update_agreement_price_in_app( $and_taxes, $subscription ) {
2528 if ( ! wcs_is_subscription( $subscription ) ) {
2529 return;
2530 }
2531
2532 $payment_method = WC_Vipps_Recurring_Helper::get_payment_method( $subscription );
2533 if ( $this->id !== $payment_method ) {
2534 return;
2535 }
2536
2537 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] Totals were recalculated. Marking subscription for update in the Vipps/MobilePay app.', WC_Vipps_Recurring_Helper::get_id( $subscription ) ) );
2538 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_UPDATE_IN_APP, 1 );
2539 $subscription->save();
2540 }
2541
2542 /**
2543 * Initialize webhooks if we don't already have one
2544 *
2545 * @throws WC_Vipps_Recurring_Exception
2546 * @throws WC_Vipps_Recurring_Temporary_Exception
2547 * @throws WC_Vipps_Recurring_Config_Exception
2548 */
2549 private function webhook_initialize_initial() {
2550 $msn = $this->merchant_serial_number;
2551
2552 // We cannot use webhooks without the MSN being set.
2553 if ( empty( $msn ) ) {
2554 return;
2555 }
2556
2557 $webhooks = $this->api->get_webhooks( $msn );
2558 $local_webhooks = $this->webhook_get_local();
2559 $callback_url = $this->webhook_callback_url();
2560
2561 // Delete webhooks that are not for our MSN + hostname combination. This prevents us from conflicting with Pay with WooCommerce for Vipps and MobilePay
2562 $deletion_tracker = [];
2563
2564 $webhook_initialized = false;
2565
2566 foreach ( $webhooks['webhooks'] as $webhook ) {
2567 if ( ! isset( $local_webhooks[ $msn ][ $webhook['id'] ] ) ) {
2568 // this webhook is not created by us, so we can continue
2569 continue;
2570 }
2571
2572 // If we have multiple webhooks for our MSN we can delete the other ones. We do not want duplicates.
2573 if ( $webhook_initialized ) {
2574 $deletion_tracker[] = $webhook['id'];
2575 }
2576
2577 $webhook_initialized = true;
2578 }
2579
2580 // Create webhook and save it to WC_Vipps_Recurring_Helper::OPTION_WEBHOOKS
2581 if ( ! $webhook_initialized ) {
2582 $response = $this->api->register_webhook( $msn );
2583
2584 $local_webhooks[ $msn ][ $response['id'] ] = [
2585 'secret' => $response['secret'],
2586 'url' => $callback_url
2587 ];
2588 }
2589
2590 // Delete superfluous webhooks
2591 if ( ! empty( $deletion_tracker ) ) {
2592 foreach ( $deletion_tracker as $id ) {
2593 try {
2594 $this->api->delete_webhook( $msn, $id );
2595 unset( $local_webhooks[ $msn ][ $id ] );
2596 } catch ( Exception $e ) {
2597 WC_Vipps_Recurring_Logger::log( sprintf( 'Failed to delete webhook %s. Error: %s', $id, $e->getMessage() ) );
2598 }
2599 }
2600 }
2601
2602 update_option( WC_Vipps_Recurring_Helper::OPTION_WEBHOOKS, $local_webhooks );
2603 }
2604
2605 /**
2606 * Check that our local webhooks point to this site.
2607 * If they don't we should delete them, otherwise we end up with hitting the limit of 5.
2608 *
2609 * @throws WC_Vipps_Recurring_Exception
2610 * @throws WC_Vipps_Recurring_Temporary_Exception
2611 * @throws WC_Vipps_Recurring_Config_Exception
2612 */
2613 public function webhook_ensure_this_site(): bool {
2614 $msn = $this->merchant_serial_number;
2615
2616 // We cannot use webhooks without the MSN being set.
2617 if ( empty( $msn ) ) {
2618 return false;
2619 }
2620
2621 $local_webhooks = $this->webhook_get_local();
2622 $callback_url = $this->webhook_callback_url();
2623 $callback_url_base = strtok( $callback_url, "?" );
2624
2625 // Make sure we don't do this more than once every 30 minutes, and only if the callback url base changes.
2626 $webhook_transient = "vipps-recurring-webhooks-site";
2627 if ( get_transient( $webhook_transient ) === $callback_url_base ) {
2628 return true;
2629 }
2630
2631 set_transient( $webhook_transient, $callback_url_base, 1800 );
2632
2633 if ( empty( $local_webhooks[ $msn ] ) ) {
2634 return false;
2635 }
2636
2637 $remote_webhooks = $this->api->get_webhooks( $msn );
2638
2639 $ok = true;
2640 foreach ( $local_webhooks[ $msn ] as $webhook_id => $webhook ) {
2641 $webhook_base_url = strtok( $webhook['url'], "?" );
2642
2643 if ( $webhook_base_url === $callback_url_base ) {
2644 continue;
2645 }
2646
2647 // Check that this webhookId indeed exists in $remote_webhooks before we attempt to delete it
2648 $webhook_exists = ! empty( array_filter( $remote_webhooks['webhooks'], function ( $remote_webhook ) use ( $webhook_id ) {
2649 return $remote_webhook['id'] === $webhook_id;
2650 } ) );
2651
2652 if ( $webhook_exists ) {
2653 $this->api->delete_webhook( $msn, $webhook_id );
2654 }
2655
2656 unset( $local_webhooks[ $msn ][ $webhook_id ] );
2657
2658 $ok = false;
2659 }
2660
2661 update_option( WC_Vipps_Recurring_Helper::OPTION_WEBHOOKS, $local_webhooks );
2662
2663 return $ok;
2664 }
2665
2666 /**
2667 * Delete all webhooks during uninstall for this MSN
2668 *
2669 * @throws WC_Vipps_Recurring_Exception
2670 * @throws WC_Vipps_Recurring_Temporary_Exception
2671 * @throws WC_Vipps_Recurring_Config_Exception
2672 */
2673 public function webhook_teardown() {
2674 $msn = $this->merchant_serial_number;
2675
2676 if ( empty( $msn ) ) {
2677 return;
2678 }
2679
2680 $local_webhooks = $this->webhook_get_local();
2681
2682 if ( empty( $local_webhooks[ $msn ] ) ) {
2683 return;
2684 }
2685
2686 $remote_webhooks = $this->api->get_webhooks( $msn );
2687
2688 // We can have multiple MSNs, hence the nested loop
2689 foreach ( $local_webhooks as $webhooks ) {
2690 foreach ( $webhooks as $webhook_id => $webhook ) {
2691 // Check that this webhookId indeed exists in $remote_webhooks before we attempt to delete it
2692 $webhook_exists = ! empty( array_filter( $remote_webhooks['webhooks'], function ( $remote_webhook ) use ( $webhook_id ) {
2693 return $remote_webhook['id'] === $webhook_id;
2694 } ) );
2695
2696 if ( ! $webhook_exists ) {
2697 continue;
2698 }
2699
2700 $this->api->delete_webhook( $msn, $webhook_id );
2701 }
2702 }
2703
2704 delete_option( WC_Vipps_Recurring_Helper::OPTION_WEBHOOKS );
2705 }
2706
2707 public function webhook_get_local(): array {
2708 return get_option( WC_Vipps_Recurring_Helper::OPTION_WEBHOOKS, [ $this->merchant_serial_number => [] ] );
2709 }
2710
2711 public function webhook_initialize() {
2712 try {
2713 $this->webhook_ensure_this_site();
2714 $this->webhook_initialize_initial();
2715 } catch ( Exception $e ) {
2716 WC_Vipps_Recurring_Logger::log( sprintf( 'Failed to initialize webhooks. Error: %s', $e->getMessage() ) );
2717 }
2718 }
2719
2720 public function webhook_callback_url(): string {
2721 $base_url = home_url( '/', 'https' );
2722
2723 $args = [ 'callback' => 'webhook' ];
2724 $action = 'wc_gateway_vipps_recurring';
2725
2726 if ( ! get_option( 'permalink_structure' ) ) {
2727 $args['wc-api'] = $action;
2728 } else {
2729 $base_url = trailingslashit( home_url( "wc-api/$action", 'https' ) );
2730 }
2731
2732 return add_query_arg( $args, $base_url );
2733 }
2734
2735 private function maybe_get_subscription_from_agreement_webhook( array $webhook_data ): ?WC_Subscription {
2736 if ( isset( $webhook_data['agreementId'] ) ) {
2737 $agreement_id = $webhook_data['agreementId'];
2738
2739 $args = [
2740 'subscriptions_per_page' => 1,
2741 'subscription_status' => [ 'active', 'pending', 'on-hold' ],
2742 ];
2743
2744 $args = WC_Vipps_Recurring_Helper::add_meta_query_to_args( $args, WC_Vipps_Recurring_Helper::META_AGREEMENT_ID, '=', $agreement_id, $this->use_high_performance_order_storage() );
2745
2746 $subscriptions = wcs_get_subscriptions( $args );
2747
2748 if ( ! empty( $subscriptions ) ) {
2749 return array_pop( $subscriptions );
2750 }
2751 }
2752
2753 // Otherwise we were unable to find the subscription
2754 return null;
2755 }
2756
2757 /**
2758 * @throws WC_Vipps_Recurring_Exception
2759 * @throws WC_Vipps_Recurring_Temporary_Exception
2760 * @throws WC_Vipps_Recurring_Config_Exception
2761 */
2762 public function handle_webhook_callback( array $webhook_data ): void {
2763 WC_Vipps_Recurring_Logger::log( sprintf( "Handling webhook with data: %s", json_encode( $webhook_data ) ) );
2764
2765 $event_type = $webhook_data['eventType'];
2766
2767 if ( in_array( $event_type, [
2768 'recurring.charge-reserved.v1',
2769 'recurring.charge-captured.v1',
2770 'recurring.charge-canceled.v1',
2771 'recurring.charge-failed.v1',
2772 ] ) ) {
2773 $order_id = null;
2774
2775 $args = [
2776 'limit' => 1,
2777 'payment_method' => $this->id,
2778 'order_by' => 'post_date'
2779 ];
2780
2781 $args = WC_Vipps_Recurring_Helper::add_meta_query_to_args( $args, WC_Vipps_Recurring_Helper::META_CHARGE_ID, '=', $webhook_data['chargeId'], $this->use_high_performance_order_storage() );
2782
2783 $orders = wc_get_orders( $args );
2784
2785 $order = array_pop( $orders );
2786
2787 if ( $order ) {
2788 $order_id = WC_Vipps_Recurring_Helper::get_id( $order );
2789 }
2790
2791 if ( empty( $order_id ) ) {
2792 return;
2793 }
2794
2795 $this->check_charge_status( $order_id );
2796 }
2797
2798 // Customers can now cancel their agreements directly from the app.
2799 if ( $event_type === 'recurring.agreement-stopped.v1' ) {
2800 // If the initiator of this webhook is ourselves, we must discard it.
2801 // Otherwise, we risk cancelling payment gateway changes etc.
2802 if ( $webhook_data['actor'] === 'MERCHANT' ) {
2803 return;
2804 }
2805
2806 $subscription = $this->maybe_get_subscription_from_agreement_webhook( $webhook_data );
2807
2808 if ( empty( $subscription ) ) {
2809 return;
2810 }
2811
2812 $message = __( 'Subscription cancelled by the customer via the Vipps MobilePay app.', 'woo-vipps' );
2813 $subscription->set_status( 'cancelled', $message );
2814 $subscription->save();
2815 }
2816 }
2817
2818 public function cart_supports_checkout( $cart = null ) {
2819 if ( ! $cart ) {
2820 $cart = WC()->cart;
2821 }
2822
2823 if ( ! $cart ) {
2824 return false;
2825 }
2826
2827 # Not supported by Vipps MobilePay Checkout
2828 if ( $cart->cart_contents_total <= 0 ) {
2829 return false;
2830 }
2831
2832 $supports = WC_Vipps_Recurring::get_instance()->gateway_should_be_active()
2833 && WC_Subscriptions_Cart::cart_contains_subscription();
2834
2835 return apply_filters( 'wc_vipps_recurring_cart_supports_checkout', $supports, $cart );
2836 }
2837
2838 public function checkout_is_available() {
2839 if ( ! $this->checkout_enabled
2840 || ! $this->is_available()
2841 || ! $this->cart_supports_checkout() ) {
2842 return false;
2843 }
2844
2845 $checkout_id = wc_get_page_id( 'vipps_recurring_checkout' );
2846 if ( ! $checkout_id ) {
2847 return false;
2848 }
2849
2850 if ( ! get_post_status( $checkout_id ) ) {
2851 delete_option( 'woocommerce_vipps_recurring_checkout_page_id' );
2852
2853 return false;
2854 }
2855
2856 return apply_filters( 'wc_vipps_recurring_checkout_available', $checkout_id, $this );
2857 }
2858
2859 // Creating a partial order & subscription
2860 // Heavily based on the single payments plugins logic. The comments are also copied for readability.
2861 /**
2862 * @throws WC_Data_Exception
2863 * @throws Exception
2864 */
2865 public function create_partial_order( $checkout = false ): int {
2866 $order_id = apply_filters( 'woocommerce_create_order', null );
2867 if ( $order_id ) {
2868 return $order_id;
2869 }
2870
2871 // This is necessary for some plugins, like YITH Dynamic Pricing, that adds filters to get_price depending on whether is_checkout is true.
2872 // so basically, since we are impersonating WC_Checkout here, we should define this constant too
2873 wc_maybe_define_constant( 'WOOCOMMERCE_CHECKOUT', true );
2874
2875 // In *some* cases you may need to actually load classes and reload the cart, because some plugins do not load when DOING_AJAX.
2876 do_action( 'wc_vipps_recurring_express_checkout_before_calculate_totals' );
2877
2878 WC()->cart->calculate_fees();
2879 WC()->cart->calculate_shipping();
2880 WC()->cart->calculate_totals();
2881 do_action( 'wc_vipps_recurring_before_create_express_checkout_order', WC()->cart );
2882
2883 $order_id = absint( WC()->session->get( 'order_awaiting_payment' ) );
2884 $cart_hash = WC()->cart->get_cart_hash();
2885 $order = $order_id ? wc_get_order( $order_id ) : null;
2886
2887 /**
2888 * If there is an order pending payment, we can resume it here so
2889 * long as it has not changed. If the order has changed, i.e.
2890 * different items or cost, create a new order. We use a hash to
2891 * detect changes which is based on cart items + order total.
2892 */
2893 if ( $order && $order->has_cart_hash( $cart_hash ) && $order->has_status( array( 'pending', 'failed' ) ) ) {
2894 /**
2895 * Indicates that we are resuming checkout for an existing order (which is pending payment, and which
2896 * has not changed since it was added to the current shopping session).
2897 *
2898 * @param int $order_id The ID of the order being resumed.
2899 *
2900 * @since 3.0.0 or earlier
2901 *
2902 */
2903 do_action( 'woocommerce_resume_order', $order_id );
2904
2905 // Remove all items - we will re-add them later.
2906 $order->remove_order_items();
2907 } else {
2908 $order = new WC_Order();
2909 }
2910
2911 // We store this in the order, so we don't have to access the cart when initiating payment. This allows us to restart orders etc.
2912 $needs_shipping = WC()->cart->needs_shipping();
2913
2914 $order->set_payment_method( $this );
2915
2916 try {
2917 if ( $checkout ) {
2918 $order->update_meta_data( WC_Vipps_Recurring_Helper::META_ORDER_IS_CHECKOUT, 1 );
2919 $order->set_payment_method_title( 'Vipps/MobilePay Recurring Checkout' );
2920 } else {
2921 $order->set_payment_method_title( 'Vipps/MobilePay Recurring Express Checkout' );
2922 }
2923
2924 // We use 'checkout' as the created_via key as per requests, but allow merchants to use their own.
2925 $created_via = apply_filters( 'wc_vipps_recurring_express_checkout_created_via', 'checkout', $order, $checkout );
2926
2927 $order->set_cart_hash( $cart_hash );
2928 $order->set_created_via( $created_via );
2929
2930 $order->update_meta_data( WC_Vipps_Recurring_Helper::META_ORDER_IS_EXPRESS, 1 );
2931
2932 # To help with address fields, scope etc in initiate payment
2933 $order->update_meta_data( WC_Vipps_Recurring_Helper::META_ORDER_NEEDS_SHIPPING, $needs_shipping );
2934
2935 $order->set_customer_id( apply_filters( 'woocommerce_checkout_customer_id', get_current_user_id() ) );
2936 $order->set_currency( get_woocommerce_currency() );
2937 $order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );
2938 $order->set_customer_ip_address( WC_Geolocation::get_ip_address() );
2939 $order->set_customer_user_agent( wc_get_user_agent() );
2940 $order->set_discount_total( WC()->cart->get_discount_total() );
2941 $order->set_discount_tax( WC()->cart->get_discount_tax() );
2942 $order->set_cart_tax( WC()->cart->get_cart_contents_tax() + WC()->cart->get_fee_tax() );
2943
2944 // Use these methods directly - they should be safe.
2945 WC()->checkout->create_order_line_items( $order, WC()->cart );
2946 WC()->checkout->create_order_fee_lines( $order, WC()->cart );
2947 WC()->checkout->create_order_tax_lines( $order, WC()->cart );
2948 WC()->checkout->create_order_coupon_lines( $order, WC()->cart );
2949
2950 if ( $needs_shipping ) {
2951 WC()->checkout->create_order_shipping_lines( $order, WC()->session->get( 'chosen_shipping_methods' ), WC()->shipping->get_packages() );
2952 }
2953
2954 do_action( 'wc_vipps_recurring_before_calculate_totals_partial_order', $order );
2955 $order->calculate_totals();
2956
2957 // Added to support third-party plugins that wants to do stuff with the order before it is saved.
2958 do_action( 'woocommerce_checkout_create_order', $order, array() );
2959
2960 $order_id = $order->save();
2961
2962 do_action( 'wc_vipps_recurring_express_checkout_order_created', $order_id );
2963
2964 // Normally done by the WC_Checkout::create_order method, so call it here too.
2965 do_action( 'woocommerce_checkout_update_order_meta', $order_id, array() );
2966
2967 // // It isn't possible to remove the javascript or 'after order notice' actions, because these are added as closures
2968 // // before anything else is run. But we can disable the hook that saves data.
2969 // if (WC_Gateway_Vipps::instance()->get_option('vippsorderattribution') != 'yes') {
2970 remove_all_filters( 'woocommerce_order_save_attribution_data' );
2971 // }
2972
2973 do_action( 'woocommerce_checkout_order_created', $order );
2974 } catch ( Exception $exception ) {
2975 if ( $order instanceof WC_Order ) {
2976 $order->get_data_store()->release_held_coupons( $order );
2977 do_action( 'woocommerce_checkout_order_exception', $order );
2978 }
2979
2980 // Any errors gets passed upstream
2981 throw $exception;
2982 }
2983
2984 return $order_id;
2985 }
2986
2987 /**
2988 * @throws Exception
2989 */
2990 public function create_or_get_anonymous_system_customer(): WC_Customer {
2991 $customer_id = get_option( WC_Vipps_Recurring_Helper::OPTION_ANONYMOUS_SYSTEM_CUSTOMER_ID );
2992
2993 // Create a user if it does not exist
2994 if ( ! get_user_by( 'ID', $customer_id ) ) {
2995 $email = WC_Vipps_Recurring_Helper::FAKE_USER_EMAIL;
2996
2997 $customer = get_user_by( 'email', $email );
2998 if ( ! $customer ) {
2999 $username = wc_create_new_customer_username( $email );
3000 $customer_id = wc_create_new_customer( $email, $username, null, [
3001 'first_name' => 'Anonymous Vipps MobilePay Customer',
3002 'user_nicename' => __( 'Anonymous Vipps MobilePay Customer', 'woo-vipps' ),
3003 ] );
3004 } else {
3005 $customer_id = $customer->ID;
3006 }
3007
3008 update_option( WC_Vipps_Recurring_Helper::OPTION_ANONYMOUS_SYSTEM_CUSTOMER_ID, $customer_id );
3009 }
3010
3011 return new WC_Customer( $customer_id );
3012 }
3013
3014 public function create_partial_subscription_groups_from_order( WC_Order $order ): array {
3015 $subscription_groups = [];
3016
3017 // Group the order items into subscription groups.
3018 foreach ( $order->get_items() as $item ) {
3019 $product = $item->get_product();
3020
3021 if ( ! WC_Subscriptions_Product::is_subscription( $product ) ) {
3022 continue;
3023 }
3024
3025 $subscription_groups[ wcs_get_subscription_item_grouping_key( $item ) ][] = $item;
3026 }
3027
3028 return $subscription_groups;
3029 }
3030
3031 /**
3032 * Based on WC_REST_Subscriptions_Controller::create_subscriptions_from_order
3033 *
3034 * @throws Exception
3035 */
3036 public function create_partial_subscriptions_from_order( WC_Order $order ) {
3037 $order_id = WC_Vipps_Recurring_Helper::get_id( $order );
3038
3039 if ( ! $order->get_customer_id() ) {
3040 return false;
3041 }
3042
3043 if ( wcs_order_contains_subscription( $order, 'any' ) ) {
3044 return false;
3045 }
3046
3047 $subscriptions = [];
3048 $subscription_groups = $this->create_partial_subscription_groups_from_order( $order );
3049
3050 if ( empty( $subscription_groups ) ) {
3051 return false;
3052 }
3053
3054 foreach ( $subscription_groups as $items ) {
3055 // Get the first item in the group to use as the base for the subscription.
3056 $product = $items[0]->get_product();
3057
3058 $start_date = wcs_get_datetime_utc_string( $order->get_date_created( 'edit' ) );
3059 $subscription = wcs_create_subscription( [
3060 'order_id' => $order_id,
3061 'created_via' => $order->get_created_via( 'edit' ),
3062 'start_date' => $start_date,
3063 'status' => $order->is_paid() ? 'active' : 'pending',
3064 'billing_period' => apply_filters( 'wc_vipps_recurring_checkout_product_billing_period', WC_Subscriptions_Product::get_period( $product ), $product ),
3065 'billing_interval' => apply_filters( 'wc_vipps_recurring_checkout_product_billing_interval', WC_Subscriptions_Product::get_interval( $product ), $product ),
3066 'customer_note' => $order->get_customer_note(),
3067 ] );
3068
3069 if ( is_wp_error( $subscription ) ) {
3070 throw new Exception( $subscription->get_error_message() );
3071 }
3072
3073 wcs_copy_order_address( $order, $subscription );
3074
3075 $subscription->update_dates( [
3076 'trial_end' => WC_Subscriptions_Product::get_trial_expiration_date( $product, $start_date ),
3077 'next_payment' => WC_Subscriptions_Product::get_first_renewal_payment_date( $product, $start_date ),
3078 'end' => WC_Subscriptions_Product::get_expiration_date( $product, $start_date ),
3079 ] );
3080
3081 $subscription->set_payment_method( $order->get_payment_method() );
3082
3083 wcs_copy_order_meta( $order, $subscription, 'subscription' );
3084
3085 // Add items.
3086 $subscription_needs_shipping = false;
3087 foreach ( $items as $item ) {
3088 // Create order line item.
3089 $item_id = wc_add_order_item(
3090 $subscription->get_id(),
3091 [
3092 'order_item_name' => $item->get_name(),
3093 'order_item_type' => $item->get_type(),
3094 ]
3095 );
3096
3097 $subscription_item = $subscription->get_item( $item_id );
3098
3099 wcs_copy_order_item( $item, $subscription_item );
3100
3101 // Don't include sign-up fees or $0 trial periods when setting the subscriptions item totals.
3102 wcs_set_recurring_item_total( $subscription_item );
3103
3104 $subscription_item->save();
3105
3106 // Check if this subscription will need shipping.
3107 if ( ! $subscription_needs_shipping ) {
3108 $product = $item->get_product();
3109
3110 if ( $product ) {
3111 $subscription_needs_shipping = $product->needs_shipping() && ! WC_Subscriptions_Product::needs_one_time_shipping( $product );
3112 }
3113 }
3114 }
3115
3116 // Add coupons.
3117 foreach ( $order->get_coupons() as $coupon_item ) {
3118 $coupon = new WC_Coupon( $coupon_item->get_code() );
3119
3120 try {
3121 // validate_subscription_coupon_for_order will throw an exception if the coupon cannot be applied to the subscription.
3122 WC_Subscriptions_Coupon::validate_subscription_coupon_for_order( true, $coupon, $subscription );
3123
3124 $subscription->apply_coupon( $coupon->get_code() );
3125 } catch ( Exception $e ) {
3126 // Do nothing. The coupon will not be applied to the subscription.
3127 }
3128 }
3129
3130 // Add shipping.
3131 if ( $subscription_needs_shipping ) {
3132 foreach ( $order->get_shipping_methods() as $shipping_item ) {
3133 $rate = new WC_Shipping_Rate( $shipping_item->get_method_id(), $shipping_item->get_method_title(), $shipping_item->get_total(), $shipping_item->get_taxes(), $shipping_item->get_instance_id() );
3134
3135 $item = new WC_Order_Item_Shipping();
3136 $item->set_order_id( $subscription->get_id() );
3137 $item->set_shipping_rate( $rate );
3138
3139 $subscription->add_item( $item );
3140 }
3141 }
3142
3143 // Add fees.
3144 foreach ( $order->get_fees() as $fee_item ) {
3145 if ( ! apply_filters( 'wcs_should_copy_fee_item_to_subscription', true, $fee_item, $subscription, $order ) ) {
3146 continue;
3147 }
3148
3149 $item = new WC_Order_Item_Fee();
3150 $item->set_props(
3151 array(
3152 'name' => $fee_item->get_name(),
3153 'tax_class' => $fee_item->get_tax_class(),
3154 'amount' => $fee_item->get_amount(),
3155 'total' => $fee_item->get_total(),
3156 'total_tax' => $fee_item->get_total_tax(),
3157 'taxes' => $fee_item->get_taxes(),
3158 )
3159 );
3160
3161 $subscription->add_item( $item );
3162 }
3163
3164 // Remove this action to avoid making an unnecessary API request
3165 remove_action( 'woocommerce_order_after_calculate_totals', [
3166 $this,
3167 'update_agreement_price_in_app'
3168 ] );
3169
3170 $subscription->calculate_totals();
3171 $subscription->save();
3172
3173 $subscriptions[] = wcs_get_subscription( $subscription->get_id() );
3174 }
3175
3176 return $subscriptions;
3177 }
3178
3179 public function maybe_delete_subscription_later( $subscription_id ) {
3180 if ( $this->get_option( 'checkout_cleanup_abandoned_orders' ) !== 'yes' ) {
3181 return;
3182 }
3183
3184 if ( ! wp_next_scheduled( 'woocommerce_vipps_recurring_delete_pending_subscription', [ $subscription_id ] ) ) {
3185 wp_schedule_single_event( time() + 7200, 'woocommerce_vipps_recurring_delete_pending_subscription', [ $subscription_id ] );
3186 }
3187 }
3188
3189 public function maybe_delete_subscription( $subscription_id ): bool {
3190 if ( is_a( $subscription_id, 'WC_Subscription' ) ) {
3191 $subscription = $subscription_id;
3192 $subscription_id = $subscription->get_id();
3193 } else {
3194 $subscription = wcs_get_subscription( $subscription_id );
3195 }
3196
3197 if ( ! $subscription ) {
3198 return false;
3199 }
3200
3201 if ( $this->id !== $subscription->get_payment_method() ) {
3202 return false;
3203 }
3204
3205 $express = WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_ORDER_IS_EXPRESS );
3206 $empty_email = trim( $subscription->get_billing_email() ) === trim( WC_Vipps_Recurring_Helper::FAKE_USER_EMAIL ) || ! $subscription->get_billing_email();
3207
3208 WC_Vipps_Recurring_Logger::log( sprintf( "[%s] Running maybe_delete_subscription, (is express: %s, empty email: %s)", $subscription_id, $express ? 'Yes' : 'No', $empty_email ? 'Yes' : 'No' ) );
3209
3210 if ( ! $express ) {
3211 return false;
3212 }
3213
3214 if ( ! $empty_email ) {
3215 return false;
3216 }
3217
3218 if ( $this->get_option( 'checkout_cleanup_abandoned_orders' ) !== 'yes' ) {
3219 return false;
3220 }
3221
3222 WC_Vipps_Recurring_Logger::log( sprintf( "[%s] Subscription marked for deletion because it has no billing details after two hours. Billing email was: %s", WC_Vipps_Recurring_Helper::get_id( $subscription ), $subscription->get_billing_email() ) );
3223 $subscription->add_order_note( __( 'This subscription has been automatically marked for deletion as it has no billing details after two hours.', 'woo-vipps' ) );
3224
3225 WC_Vipps_Recurring_Helper::update_meta_data( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_MARKED_FOR_DELETION, 1 );
3226 $subscription->save();
3227
3228 return true;
3229 }
3230
3231 /**
3232 * @throws WC_Vipps_Recurring_Config_Exception
3233 * @throws WC_Vipps_Recurring_Exception
3234 * @throws WC_Vipps_Recurring_Temporary_Exception
3235 */
3236 public function cancel_subscription( $subscription_id, $agreement_id ): void {
3237 $subscription = wcs_get_subscription( $subscription_id );
3238
3239 WC_Vipps_Recurring_Logger::log( sprintf( '[%s] cancel_subscription for agreement: %s', $subscription_id, $agreement_id ) );
3240
3241 // Prevent temporary cancellations from reaching this code
3242 if ( ! $subscription ) {
3243 return;
3244 }
3245
3246 $new_status = $subscription->get_status();
3247 if ( $new_status !== 'cancelled' ) {
3248 return;
3249 }
3250
3251 if ( get_transient( 'cancel_subscription_lock' . $subscription_id ) ) {
3252 return;
3253 }
3254
3255 set_transient( 'cancel_subscription_lock' . $subscription_id, uniqid( '', true ), 30 );
3256
3257 $agreement = $this->api->get_agreement( $agreement_id );
3258
3259 if ( $agreement->status === WC_Vipps_Agreement::STATUS_ACTIVE ) {
3260 $idempotency_key = $this->get_idempotency_key( $subscription );
3261 $this->api->cancel_agreement( $agreement_id, $idempotency_key );
3262 }
3263 }
3264
3265 // If the subscription is about to be deleted, we do not want to send the cancellation email anymore.
3266 public function overwrite_send_cancelled_email( $subscription ) {
3267 // Proxy through to the normal flow if the gateway is not ours.
3268 if ( $this->id !== $subscription->get_payment_method() ) {
3269 WC_Subscriptions_Email::send_cancelled_email( $subscription );
3270
3271 return;
3272 }
3273
3274 // Check if we should delete this subscription
3275 $this->maybe_delete_subscription( $subscription->get_id() );
3276 $subscription = wcs_get_subscription( $subscription->get_id() );
3277
3278 $marked_for_deletion = WC_Vipps_Recurring_Helper::get_meta( $subscription, WC_Vipps_Recurring_Helper::META_SUBSCRIPTION_MARKED_FOR_DELETION );
3279 if ( $marked_for_deletion ) {
3280 return;
3281 }
3282
3283 WC_Subscriptions_Email::send_cancelled_email( $subscription );
3284 }
3285 }
3286