PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.4
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.4
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 66 releases
better-payment / includes / WooCommerce / Subscriptions.php

Subscriptions.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.3.4, at includes/WooCommerce/Subscriptions.php

2,992 lines 123.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\WooCommerce;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\Classes\Handler;
7 use Better_Payment\Lite\Classes\StripeService;
8 use Better_Payment\Lite\Models\SubscriptionRelationModel;
9
10 /**
11 * Exit if accessed directly
12 */
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Better Payment subscriptions for WooCommerce — recurring payments over the
19 * Better Payment (Stripe) gateway.
20 *
21 * Self-contained and BP-native: products opt in via Better Payment product
22 * meta, subscription state lives as order meta on the parent order (no new
23 * tables, no CPT), and WP-Cron drives renewals. The first checkout saves a
24 * reusable Stripe customer + payment method (`setup_future_usage`, see
25 * Gateway::process_payment()); each due renewal creates a pending WooCommerce
26 * order and charges it server-side with an off-session PaymentIntent through
27 * StripeService. Every renewal is recorded in the Better Payment transactions
28 * table (`referer=woocommerce`) exactly like a first payment.
29 *
30 * Lifecycle (all state on the PARENT order):
31 * active — renewals are charged automatically when due.
32 * past_due — a renewal charge failed; auto-charging stops and the customer
33 * is invoiced the pending renewal order. Paying it (any route
34 * through this gateway) reactivates the subscription.
35 * cancelled — terminal; set from the admin order action, the customer's
36 * My Account cancel button (when the product allows it), or the
37 * cancel() API.
38 * completed — terminal; not set by any core flow (the renewal-cap feature
39 * was removed). The status, its label/badge, the completed
40 * email and the role sync remain so an integration may still
41 * complete a subscription (set the status meta and fire
42 * `better_payment/woocommerce/subscription_completed`).
43 *
44 * A subscription purchase is locked to THIS gateway at checkout: with a
45 * subscription product in the cart (or a renewal invoice / subscription order
46 * on the order-pay page) every other gateway is removed from the available
47 * list, because no other gateway can save the reusable off-session payment
48 * method renewals are charged with (see restrict_available_gateways()).
49 *
50 * A subscription is also bought ON ITS OWN: the cart may hold one subscription
51 * product, never a second one and never a one-off product beside it, because
52 * every subscription fact above hangs off a single parent order (see
53 * cart_composition_error()).
54 *
55 * Only loaded when WooCommerce is active (see Loader::register()).
56 *
57 * @since 2.4.0
58 */
59 class Subscriptions {
60
61 /**
62 * Product meta: 'yes' marks a product as a Better Payment subscription.
63 */
64 const PRODUCT_ENABLED_META = '_bp_subscription_enabled';
65
66 /**
67 * Product meta: billing interval count (int >= 1).
68 */
69 const PRODUCT_INTERVAL_META = '_bp_subscription_interval';
70
71 /**
72 * Product meta: billing period (day|week|month|year).
73 */
74 const PRODUCT_PERIOD_META = '_bp_subscription_period';
75
76 /**
77 * Product meta: free trial duration in days (int >= 0, 0 = no trial).
78 * An eligible customer pays NOTHING at checkout (the line
79 * calculates at $0 via the calculation-scoped
80 * zero_trial_price() filter — the displayed price stays real) and the
81 * FIRST payment is charged when the trial ends — the paid schedule
82 * starts after the trial, it is not "first cycle + trial". Eligibility
83 * is one trial per customer per product (trial_eligible()); a returning
84 * subscriber pays the regular price at checkout and renews on the plain
85 * schedule. Card collection on a $0 order happens through a Stripe
86 * setup-mode Checkout Session (Gateway::process_payment()).
87 */
88 const PRODUCT_TRIAL_DAYS_META = '_bp_subscription_trial_days';
89
90 /**
91 * Parent-order meta: 'yes' when the free trial was actually applied to
92 * this order (customer was eligible and checked out at $0). Absent for
93 * paid checkouts — including trial products bought by a returning
94 * subscriber.
95 */
96 const TRIAL_APPLIED_META = '_bp_subscription_trial_applied';
97
98 /**
99 * Product meta: custom Add to Cart button text for the subscription
100 * product ('' = WooCommerce's default label). Applied on both the single
101 * product page and the shop-loop button.
102 */
103 const BUTTON_TEXT_META = '_bp_subscription_button_text';
104
105 /**
106 * Product meta AND parent-order snapshot: 'yes' lets the customer cancel
107 * the subscription from their My Account order page.
108 */
109 const USER_CANCEL_META = '_bp_subscription_user_cancel';
110
111 /**
112 * Parent-order meta: number of renewal payments settled so far.
113 */
114 const RENEWAL_COUNT_META = '_bp_subscription_renewal_count';
115
116 /**
117 * Parent-order meta: subscription status
118 * (active|past_due|cancelled|completed).
119 */
120 const STATUS_META = '_bp_subscription_status';
121
122 /**
123 * Parent-order meta: next renewal due date (UNIX timestamp, stored as string).
124 */
125 const NEXT_PAYMENT_META = '_bp_subscription_next_payment';
126
127 /**
128 * Parent-order meta: when the most recent renewal payment settled
129 * (UNIX timestamp, stored as string). Absent until the first renewal —
130 * the initial checkout is the start date, not a renewal payment.
131 */
132 const LAST_PAYMENT_META = '_bp_subscription_last_payment';
133
134 /**
135 * Parent-order meta: who cancelled the subscription — 'customer' or
136 * 'admin' — and the acting user's id. Stamped by cancel() when the
137 * caller declares the actor, cleared by reactivate() so a subscription
138 * cancelled again later never reports a stale actor.
139 */
140 const CANCELLED_BY_TYPE_META = '_bp_subscription_cancelled_by_type';
141 const CANCELLED_BY_META = '_bp_subscription_cancelled_by';
142
143 /**
144 * Parent-order meta: billing schedule snapshot taken at activation, so a
145 * later product edit never rewrites a customer's agreed schedule.
146 */
147 const INTERVAL_META = '_bp_subscription_interval';
148 const PERIOD_META = '_bp_subscription_period';
149
150 /**
151 * Order meta: reusable Stripe identifiers saved from the first checkout.
152 */
153 const CUSTOMER_META = '_bp_stripe_customer_id';
154 const PAYMENT_METHOD_META = '_bp_stripe_payment_method_id';
155
156 /**
157 * Renewal-order meta: back-reference to the parent (subscription) order.
158 */
159 const RENEWAL_PARENT_META = '_bp_subscription_parent';
160
161 /**
162 * Parent-order meta: 'no' when the customer switched automatic renewal
163 * off from their My Account page ('' / 'yes' = renew automatically).
164 * Only meaningful while automatic renewal is enabled site-wide.
165 */
166 const AUTO_RENEW_META = '_bp_subscription_auto_renew';
167
168 /**
169 * Parent-order meta: id of the manual-renewal order that was invoiced
170 * and is still awaiting payment. Guards the hourly cron from stacking a
171 * new invoice on every run while one is already outstanding.
172 */
173 const PENDING_RENEWAL_META = '_bp_subscription_pending_renewal';
174
175 /**
176 * Option-key prefix of the E-commerce > Subscription settings (Better
177 * Payment → Settings → E-commerce → Subscription; defaults in
178 * Admin\DB::default_settings()).
179 */
180 const SETTING_PREFIX = 'better_payment_settings_ecommerce_subscription_';
181
182 /**
183 * Cron hook that processes due renewals.
184 */
185 const CRON_HOOK = 'better_payment_woocommerce_subscriptions_due';
186
187 /**
188 * Supported billing periods (strtotime-compatible units).
189 *
190 * @var string[]
191 */
192 const PERIODS = array( 'day', 'week', 'month', 'year' );
193
194 /**
195 * Wire the feature. Called from Loader::register(), i.e. only when
196 * WooCommerce is active.
197 *
198 * @return void
199 */
200 public static function register() {
201 // Product settings (admin product edit screen, General tab).
202 add_action( 'woocommerce_product_options_general_product_data', array( __CLASS__, 'render_product_fields' ) );
203 add_action( 'woocommerce_admin_process_product_object', array( __CLASS__, 'save_product_fields' ) );
204
205 // Storefront price label ("$10.00 / month").
206 add_filter( 'woocommerce_get_price_html', array( __CLASS__, 'price_html_suffix' ), 10, 2 );
207
208 // Free trial: an eligible customer checks out at $0 (the first
209 // payment is charged when the trial ends), and the $0 checkout must
210 // still run through this gateway so the card is collected for
211 // renewals — WooCommerce skips payment entirely on zero-total
212 // carts/orders unless told otherwise.
213 //
214 // The $0 is scoped to totals CALCULATION only (a get_price filter
215 // added before calculate_totals and removed right after): line
216 // totals come out $0 while the
217 // product's stored/display price stays real. Mutating the cart
218 // item's price instead (set_price(0)) made every price reader for
219 // the rest of the request see 0 against a real regular price, so
220 // the blocks cart displayed the trial as a fake sale — "~~100.00$~~
221 // 0.00$ / Save 100.00$" — on a product that is not discounted at all.
222 add_action( 'woocommerce_before_calculate_totals', array( __CLASS__, 'add_trial_price_filter' ), 20 );
223 add_action( 'woocommerce_calculate_totals', array( __CLASS__, 'remove_trial_price_filter' ) );
224 add_action( 'woocommerce_after_calculate_totals', array( __CLASS__, 'remove_trial_price_filter' ) );
225 add_filter( 'woocommerce_cart_needs_payment', array( __CLASS__, 'filter_cart_needs_payment' ) );
226 add_filter( 'woocommerce_order_needs_payment', array( __CLASS__, 'filter_order_needs_payment' ), 10, 2 );
227
228 // Custom Add to Cart button text (single product page + shop loop).
229 add_filter( 'woocommerce_product_single_add_to_cart_text', array( __CLASS__, 'filter_add_to_cart_text' ), 10, 2 );
230 add_filter( 'woocommerce_product_add_to_cart_text', array( __CLASS__, 'filter_add_to_cart_text' ), 10, 2 );
231
232 // One subscription per order: a subscription is checked out on its
233 // own, never beside a second subscription and never beside a one-off
234 // product. Enforced in two layers — refuse the add,
235 // and re-check the whole cart on the cart/checkout pages for the
236 // compositions add-to-cart validation cannot see. Both hooks are
237 // shared by the classic and blocks (Store API) surfaces.
238 add_filter( 'woocommerce_add_to_cart_validation', array( __CLASS__, 'validate_add_to_cart_composition' ), 10, 2 );
239 add_action( 'woocommerce_check_cart_items', array( __CLASS__, 'enforce_cart_composition' ) );
240
241 // Activation + renewal settlement. Both the first payment and every
242 // renewal payment (cron-charged or manually paid) converge on the
243 // module's payment-complete hook.
244 add_action( 'better_payment/woocommerce/payment_complete', array( __CLASS__, 'on_order_paid' ), 10, 2 );
245
246 // Renewal scheduler.
247 add_action( self::CRON_HOOK, array( __CLASS__, 'process_due_subscriptions' ) );
248
249 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
250 wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK );
251 }
252
253 register_deactivation_hook( BETTER_PAYMENT_FILE, array( __CLASS__, 'unschedule' ) );
254
255 // Admin: cancel action on the parent order edit screen.
256 add_filter( 'woocommerce_order_actions', array( __CLASS__, 'register_order_action' ), 10, 2 );
257 add_action( 'woocommerce_order_action_bp_cancel_subscription', array( __CLASS__, 'handle_cancel_action' ) );
258
259 // Customer: cancel button. NOT hooked on
260 // `woocommerce_order_details_after_order_table` — that hook fires on
261 // both the My Account view-order page and the order-received
262 // (thank-you) page, so the button turned up on a plain order receipt
263 // and on every order-details screen. Subscription management lives in
264 // one place: My Account > Subscriptions, whose single-subscription
265 // view calls the renderer directly (MyAccount::render_view()). Only
266 // the POST handler is hooked here.
267 add_action( 'template_redirect', array( __CLASS__, 'maybe_handle_customer_cancel' ) );
268
269 // Checkout: a subscription purchase can only go through this gateway.
270 // Any other gateway (COD, bank transfer, another Stripe plugin, …)
271 // cannot save the reusable off-session payment method renewals are
272 // charged with, so the subscription would activate and then never
273 // renew. Covers the cart/checkout (classic + blocks, which share this
274 // filter) and the order-pay page (invoiced renewals + pending
275 // subscription orders).
276 add_filter( 'woocommerce_available_payment_gateways', array( __CLASS__, 'restrict_available_gateways' ) );
277
278 // Customer Auto-Renew Control setting: customer-facing automatic-renewal
279 // on/off. Rendered only from My Account > Subscriptions, exactly like
280 // the cancel button above — never on an order-details screen.
281 add_action( 'template_redirect', array( __CLASS__, 'maybe_handle_auto_renew_toggle' ) );
282
283 // Subscriber Default/Inactive Role settings: keep the customer's
284 // role in sync with their subscription's lifecycle.
285 add_action( 'better_payment/woocommerce/subscription_activated', array( __CLASS__, 'assign_active_role' ) );
286 add_action( 'better_payment/woocommerce/subscription_cancelled', array( __CLASS__, 'assign_inactive_role' ) );
287 add_action( 'better_payment/woocommerce/subscription_completed', array( __CLASS__, 'assign_inactive_role' ) );
288 }
289
290 /**
291 * Clear the renewal cron event on plugin deactivation.
292 *
293 * @return void
294 */
295 public static function unschedule() {
296 wp_clear_scheduled_hook( self::CRON_HOOK );
297 }
298
299 /* ---------------------------------------------------------------------
300 * E-commerce > Subscription settings
301 * ------------------------------------------------------------------- */
302
303 /**
304 * Read one E-commerce > Subscription setting. Defaults resolve through
305 * DB::default_settings(), so an unsaved install behaves like the
306 * documented defaults.
307 *
308 * @param string $key Setting key without the prefix (e.g. 'renewal_process').
309 * @return string
310 */
311 public static function setting( $key ) {
312 return (string) DB::get_settings( self::SETTING_PREFIX . $key );
313 }
314
315 /**
316 * Pure: whether automatic (off-session) renewals are enabled site-wide.
317 * A manual Renewal Mode OR Automatic Stripe Charging switched off both mean
318 * every renewal is invoiced for manual payment instead of being charged.
319 * Unknown values fall back to enabled — the defaults' behavior.
320 *
321 * @param mixed $renewal_process The renewal_process setting (auto|manual).
322 * @param mixed $stripe_auto_renew The stripe_auto_renew setting (yes|no).
323 * @return bool
324 */
325 public static function auto_renewal_globally_enabled( $renewal_process, $stripe_auto_renew ) {
326 return 'manual' !== (string) $renewal_process && 'no' !== (string) $stripe_auto_renew;
327 }
328
329 /**
330 * Whether checkout should ask Stripe to keep the payment method
331 * reusable for off-session renewals. With a site-wide manual renewal
332 * policy the card would be stored without ever being charged — so it
333 * isn't stored at all.
334 *
335 * @return bool
336 */
337 public static function should_save_payment_method() {
338 return self::auto_renewal_globally_enabled( self::setting( 'renewal_process' ), self::setting( 'stripe_auto_renew' ) );
339 }
340
341 /**
342 * Whether THIS subscription renews automatically: the site-wide policy
343 * AND the customer's own auto-renewal preference (AUTO_RENEW_META,
344 * default on).
345 *
346 * @param \WC_Order $parent Parent (subscription) order.
347 * @return bool
348 */
349 public static function auto_renewal_enabled_for( $parent ) {
350 return self::should_save_payment_method()
351 && 'no' !== (string) $parent->get_meta( self::AUTO_RENEW_META );
352 }
353
354 /* ---------------------------------------------------------------------
355 * Product settings
356 * ------------------------------------------------------------------- */
357
358 /**
359 * Render the subscription fields on the product edit screen.
360 *
361 * The checkbox is always visible; every dependent field lives inside
362 * `.bp-subscription-settings-fields`, toggled by the inline script below
363 * so the group only shows while the product is a subscription. The
364 * billing schedule is ONE field — interval count + period dropdown
365 * inline ("Renew Every [3] [Month]") — not two stacked rows.
366 *
367 * @return void
368 */
369 public static function render_product_fields() {
370 global $post;
371
372 if ( ! function_exists( 'woocommerce_wp_checkbox' ) || ! $post ) {
373 return;
374 }
375
376 $enabled = get_post_meta( $post->ID, self::PRODUCT_ENABLED_META, true );
377 $interval = max( 1, (int) get_post_meta( $post->ID, self::PRODUCT_INTERVAL_META, true ) );
378 $period = self::sanitize_period( get_post_meta( $post->ID, self::PRODUCT_PERIOD_META, true ) );
379 $periods = array(
380 'day' => __( 'Day', 'better-payment' ),
381 'week' => __( 'Week', 'better-payment' ),
382 'month' => __( 'Month', 'better-payment' ),
383 'year' => __( 'Year', 'better-payment' ),
384 );
385
386 // WooCommerce (7.0+ admin styles) gives every non-checkbox
387 // .form-field label in the options panel `line-height: 40px` to
388 // vertically center a ONE-line label against its 40px input. A label
389 // that wraps at the panel's 150px label column ("Free Days Before
390 // First Charge") gets 40px per LINE — a huge gap between the two
391 // words of one label. Restore a normal line-height and re-center
392 // with padding instead, scoped to BP's own wrapper so no other
393 // plugin's (or WooCommerce's) fields are touched. The selector
394 // mirrors WooCommerce's own — same `.wc-wp-version-gte-70` guard,
395 // same `:not(:has(checkbox/radio))` carve-out — so it outranks it
396 // exactly where it applies and applies nowhere else.
397 ?>
398 <style>
399 .wc-wp-version-gte-70 .woocommerce_options_panel .bp-subscription-options .form-field:not(:has(input[type=checkbox], input[type=radio])) label {
400 line-height: 1.5;
401 padding-top: 10px;
402 }
403 </style>
404 <?php
405
406 echo '<div class="options_group bp-subscription-options">';
407
408 woocommerce_wp_checkbox(
409 array(
410 'id' => self::PRODUCT_ENABLED_META,
411 'label' => __( 'Better Payment Subscription', 'better-payment' ),
412 'description' => __( 'Bill this product on a recurring schedule through the Better Payment (Stripe) gateway.', 'better-payment' ),
413 )
414 );
415
416 echo '<div class="bp-subscription-settings-fields"' . ( 'yes' === $enabled ? '' : ' style="display:none;"' ) . '>';
417
418 ?>
419 <p class="form-field bp-subscription-schedule-field">
420 <label for="<?php echo esc_attr( self::PRODUCT_INTERVAL_META ); ?>"><?php esc_html_e( 'Renew Every', 'better-payment' ); ?></label>
421 <input
422 type="number"
423 id="<?php echo esc_attr( self::PRODUCT_INTERVAL_META ); ?>"
424 name="<?php echo esc_attr( self::PRODUCT_INTERVAL_META ); ?>"
425 value="<?php echo esc_attr( (string) $interval ); ?>"
426 min="1"
427 step="1"
428 style="width: 80px; margin-right: 8px;"
429 />
430 <select
431 id="<?php echo esc_attr( self::PRODUCT_PERIOD_META ); ?>"
432 name="<?php echo esc_attr( self::PRODUCT_PERIOD_META ); ?>"
433 style="width: auto;"
434 >
435 <?php foreach ( $periods as $value => $label ) : ?>
436 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $period, $value ); ?>><?php echo esc_html( $label ); ?></option>
437 <?php endforeach; ?>
438 </select>
439 <?php echo wp_kses_post( wc_help_tip( __( 'Billing schedule — e.g. "3" + "Month" renews every 3 months.', 'better-payment' ) ) ); ?>
440 </p>
441 <?php
442
443 woocommerce_wp_text_input(
444 array(
445 'id' => self::PRODUCT_TRIAL_DAYS_META,
446 'label' => __( 'Free Days Before First Charge', 'better-payment' ),
447 'type' => 'number',
448 'value' => (string) max( 0, (int) get_post_meta( $post->ID, self::PRODUCT_TRIAL_DAYS_META, true ) ),
449 'custom_attributes' => array(
450 'min' => '0',
451 'step' => '1',
452 ),
453 'desc_tip' => true,
454 'description' => __( 'The customer pays nothing at checkout; the first payment is charged when the trial ends. One trial per customer per product — returning subscribers pay the regular price. 0 = no trial.', 'better-payment' ),
455 )
456 );
457
458 woocommerce_wp_checkbox(
459 array(
460 'id' => self::USER_CANCEL_META,
461 'label' => __( 'Customer Can Cancel', 'better-payment' ),
462 'description' => __( 'Shows a Cancel button on this subscription under My Account → Subscriptions, so the buyer can stop future renewals without contacting you.', 'better-payment' ),
463 )
464 );
465
466 woocommerce_wp_text_input(
467 array(
468 'id' => self::BUTTON_TEXT_META,
469 'label' => __( 'Add to Cart Button Label', 'better-payment' ),
470 'type' => 'text',
471 'value' => (string) get_post_meta( $post->ID, self::BUTTON_TEXT_META, true ),
472 'placeholder' => __( 'e.g. Subscribe Now', 'better-payment' ),
473 'desc_tip' => true,
474 'description' => __( 'Replaces the Add to Cart wording for this product on both the product page and the shop listings. Leave blank to keep the WooCommerce default.', 'better-payment' ),
475 )
476 );
477
478 echo '</div>';
479 echo '</div>';
480
481 ?>
482 <script>
483 jQuery( function ( $ ) {
484 var toggleBpSubscriptionFields = function () {
485 $( '.bp-subscription-settings-fields' ).toggle(
486 $( '#<?php echo esc_js( self::PRODUCT_ENABLED_META ); ?>' ).is( ':checked' )
487 );
488 };
489
490 $( '#<?php echo esc_js( self::PRODUCT_ENABLED_META ); ?>' ).on( 'change', toggleBpSubscriptionFields );
491 toggleBpSubscriptionFields();
492 } );
493 </script>
494 <?php
495 }
496
497 /**
498 * Persist the subscription fields. Runs inside WooCommerce's own
499 * nonce-verified product save.
500 *
501 * @param \WC_Product $product The product being saved.
502 * @return void
503 */
504 public static function save_product_fields( $product ) {
505 // phpcs:disable WordPress.Security.NonceVerification.Missing -- WooCommerce verifies the product-save nonce before this hook fires.
506 $enabled = isset( $_POST[ self::PRODUCT_ENABLED_META ] ) ? 'yes' : 'no';
507 $interval = isset( $_POST[ self::PRODUCT_INTERVAL_META ] ) ? max( 1, absint( wp_unslash( $_POST[ self::PRODUCT_INTERVAL_META ] ) ) ) : 1;
508 $period = isset( $_POST[ self::PRODUCT_PERIOD_META ] ) ? self::sanitize_period( sanitize_text_field( wp_unslash( $_POST[ self::PRODUCT_PERIOD_META ] ) ) ) : 'month';
509 $trial_days = isset( $_POST[ self::PRODUCT_TRIAL_DAYS_META ] ) ? absint( wp_unslash( $_POST[ self::PRODUCT_TRIAL_DAYS_META ] ) ) : 0;
510 $user_cancel = isset( $_POST[ self::USER_CANCEL_META ] ) ? 'yes' : 'no';
511 $button_text = isset( $_POST[ self::BUTTON_TEXT_META ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::BUTTON_TEXT_META ] ) ) : '';
512 // phpcs:enable WordPress.Security.NonceVerification.Missing
513
514 $product->update_meta_data( self::PRODUCT_ENABLED_META, $enabled );
515 $product->update_meta_data( self::PRODUCT_INTERVAL_META, (string) $interval );
516 $product->update_meta_data( self::PRODUCT_PERIOD_META, $period );
517 $product->update_meta_data( self::PRODUCT_TRIAL_DAYS_META, (string) $trial_days );
518 $product->update_meta_data( self::USER_CANCEL_META, $user_cancel );
519 $product->update_meta_data( self::BUTTON_TEXT_META, $button_text );
520 }
521
522 /**
523 * Append the billing schedule to a subscription product's price HTML.
524 *
525 * @param string $price Price HTML.
526 * @param \WC_Product $product Product.
527 * @return string
528 */
529 public static function price_html_suffix( $price, $product ) {
530 if ( ! self::product_is_subscription( $product ) || '' === (string) $price ) {
531 return $price;
532 }
533
534 $interval = max( 1, (int) $product->get_meta( self::PRODUCT_INTERVAL_META ) );
535 $period = self::sanitize_period( $product->get_meta( self::PRODUCT_PERIOD_META ) );
536 $trial_days = max( 0, (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ) );
537
538 $label = sprintf(
539 /* translators: 1: price HTML, 2: billing schedule (e.g. "month" or "every 3 months") */
540 __( '%1$s / %2$s', 'better-payment' ),
541 $price,
542 self::describe_schedule( $interval, $period )
543 );
544
545 if ( $trial_days > 0 ) {
546 $label .= ' ' . sprintf(
547 /* translators: %d: number of free trial days before the first payment */
548 _n( 'with a %d-day free trial', 'with a %d-day free trial', $trial_days, 'better-payment' ),
549 $trial_days
550 );
551 }
552
553 return $label;
554 }
555
556 /**
557 * Replace the Add to Cart label with the product's custom button text.
558 * Applies only to subscription products with a non-blank custom text;
559 * every other product keeps WooCommerce's own label.
560 *
561 * @param string $text The default button text.
562 * @param mixed $product The product.
563 * @return string
564 */
565 public static function filter_add_to_cart_text( $text, $product ) {
566 if ( ! self::product_is_subscription( $product ) ) {
567 return $text;
568 }
569
570 $custom = trim( (string) $product->get_meta( self::BUTTON_TEXT_META ) );
571
572 return '' !== $custom ? $custom : $text;
573 }
574
575 /* ---------------------------------------------------------------------
576 * Detection + pure helpers
577 * ------------------------------------------------------------------- */
578
579 /**
580 * Whether a product is a Better Payment subscription product.
581 *
582 * @param mixed $product Product (or anything else — safely rejected).
583 * @return bool
584 */
585 public static function product_is_subscription( $product ) {
586 return $product instanceof \WC_Product && 'yes' === $product->get_meta( self::PRODUCT_ENABLED_META );
587 }
588
589 /**
590 * The order's line items whose product is a subscription product.
591 *
592 * @param mixed $order Order (or anything else — safely rejected).
593 * @return \WC_Order_Item_Product[]
594 */
595 public static function order_subscription_items( $order ) {
596 $items = array();
597
598 if ( ! $order instanceof \WC_Order ) {
599 return $items;
600 }
601
602 foreach ( $order->get_items() as $item ) {
603 if ( ! $item instanceof \WC_Order_Item_Product ) {
604 continue;
605 }
606
607 if ( self::product_is_subscription( $item->get_product() ) ) {
608 $items[] = $item;
609 }
610 }
611
612 return $items;
613 }
614
615 /**
616 * Whether the order contains at least one subscription product.
617 *
618 * @param \WC_Order $order Order.
619 * @return bool
620 */
621 public static function order_contains_subscription( $order ) {
622 return count( self::order_subscription_items( $order ) ) > 0;
623 }
624
625 /**
626 * Whether the current cart contains at least one subscription product.
627 *
628 * @return bool
629 */
630 public static function cart_contains_subscription() {
631 if ( ! function_exists( 'WC' ) || null === WC()->cart ) {
632 return false;
633 }
634
635 foreach ( WC()->cart->get_cart() as $cart_item ) {
636 if ( isset( $cart_item['data'] ) && self::product_is_subscription( $cart_item['data'] ) ) {
637 return true;
638 }
639 }
640
641 return false;
642 }
643
644 /* ---------------------------------------------------------------------
645 * Cart composition (one subscription per order)
646 * ------------------------------------------------------------------- */
647
648 /**
649 * Whether the one-subscription-per-order rule is enforced. Read live on
650 * every check so a site can opt out per request.
651 *
652 * @return bool
653 */
654 public static function single_subscription_cart_enforced() {
655 /**
656 * Filters whether a cart may hold only one subscription and nothing
657 * else. Turning this off does NOT make the engine multi-subscription
658 * — see cart_composition_error() for what actually breaks.
659 *
660 * @since 2.4.0
661 *
662 * @param bool $enforced Default true.
663 */
664 return (bool) apply_filters( 'better_payment/woocommerce/single_subscription_cart', true );
665 }
666
667 /**
668 * Pure: the storefront error a cart composition produces, or '' when the
669 * cart may be checked out.
670 *
671 * A Better Payment subscription is bought on its own because every
672 * subscription fact lives on ONE parent order: activate_subscription()
673 * snapshots the schedule from a single item, one Stripe payment method is
674 * stored per order, and create_renewal_order() re-adds that order's
675 * subscription products on every cycle. So a second subscription in the
676 * same order would be billed on the first one's schedule, and a one-off
677 * product would be silently re-charged at every renewal.
678 *
679 * Identity is the line's PRODUCT id, not the line: two lines of the same
680 * subscription product (two variations, or a re-add) are one subscription
681 * bought more than once — which the cart quantity field already allows —
682 * so only a second, DIFFERENT subscription product is refused.
683 *
684 * @param mixed $lines Cart lines as array( 'product_id' => int, 'is_subscription' => bool ).
685 * @return string Error message, or '' when the composition is allowed.
686 */
687 public static function cart_composition_error( $lines ) {
688 $subscriptions = array();
689 $others = 0;
690
691 foreach ( (array) $lines as $line ) {
692 if ( ! is_array( $line ) ) {
693 continue;
694 }
695
696 if ( empty( $line['is_subscription'] ) ) {
697 ++$others;
698 continue;
699 }
700
701 $product_id = isset( $line['product_id'] ) ? (int) $line['product_id'] : 0;
702 $subscriptions[ $product_id ] = true;
703 }
704
705 if ( count( $subscriptions ) > 1 ) {
706 return __( 'Only one subscription can be purchased at a time. Please remove the other subscription from your cart and buy it separately.', 'better-payment' );
707 }
708
709 if ( count( $subscriptions ) > 0 && $others > 0 ) {
710 return __( 'A subscription has to be purchased on its own. Please remove the other products from your cart and buy them separately.', 'better-payment' );
711 }
712
713 return '';
714 }
715
716 /**
717 * The current cart described for cart_composition_error(). Empty (and so
718 * always valid) without WooCommerce.
719 *
720 * The key is `product_id` — the PARENT id for a variation — because that
721 * is what `woocommerce_add_to_cart_validation` reports for the product
722 * being added, and the two must be comparable.
723 *
724 * @return array
725 */
726 public static function cart_composition_lines() {
727 $lines = array();
728
729 if ( ! function_exists( 'WC' ) || null === WC()->cart ) {
730 return $lines;
731 }
732
733 foreach ( WC()->cart->get_cart() as $cart_item ) {
734 $lines[] = array(
735 'product_id' => isset( $cart_item['product_id'] ) ? (int) $cart_item['product_id'] : 0,
736 'is_subscription' => self::product_is_subscription( isset( $cart_item['data'] ) ? $cart_item['data'] : null ),
737 );
738 }
739
740 return $lines;
741 }
742
743 /**
744 * Refuse an add to cart that would break the one-subscription-per-order
745 * rule (`woocommerce_add_to_cart_validation`).
746 *
747 * The Store API applies this same filter and converts the notice into its
748 * error response, so the blocks cart is covered without a second hook.
749 *
750 * @param bool $passed Validation result so far.
751 * @param int $product_id Product being added (parent id for a variation).
752 * @return bool
753 */
754 public static function validate_add_to_cart_composition( $passed, $product_id ) {
755 if ( ! $passed || ! function_exists( 'wc_get_product' ) || ! self::single_subscription_cart_enforced() ) {
756 return $passed;
757 }
758
759 $lines = self::cart_composition_lines();
760 $lines[] = array(
761 'product_id' => (int) $product_id,
762 'is_subscription' => self::product_is_subscription( wc_get_product( $product_id ) ),
763 );
764
765 $error = self::cart_composition_error( $lines );
766
767 if ( '' === $error ) {
768 return $passed;
769 }
770
771 wc_add_notice( $error, 'error' );
772
773 return false;
774 }
775
776 /**
777 * Re-check the whole cart's composition (`woocommerce_check_cart_items`,
778 * which runs on the cart AND checkout pages, classic and blocks).
779 *
780 * Catches what add-to-cart validation cannot: a cart filled before the
781 * product became a subscription, an order-again, or any add that skipped
782 * validation. The notice blocks checkout until a line is removed — the
783 * cart is never emptied for the customer.
784 *
785 * @return void
786 */
787 public static function enforce_cart_composition() {
788 if ( ! self::single_subscription_cart_enforced() ) {
789 return;
790 }
791
792 $error = self::cart_composition_error( self::cart_composition_lines() );
793
794 if ( '' !== $error ) {
795 wc_add_notice( $error, 'error' );
796 }
797 }
798
799 /* ---------------------------------------------------------------------
800 * Free trial ($0 at checkout, first payment at trial end)
801 * ------------------------------------------------------------------- */
802
803 /**
804 * Whether the current customer may use a product's free trial. One trial
805 * per customer per product: anyone who has (or ever had) a Better
806 * Payment subscription containing the product pays the regular price —
807 * otherwise cancelling and re-subscribing would chain free trials
808 * forever. Guests cannot be checked and are given the benefit of the
809 * doubt.
810 *
811 * @param mixed $product The product (non-products are ineligible).
812 * @return bool
813 */
814 public static function trial_eligible( $product ) {
815 $product_id = $product instanceof \WC_Product ? (int) $product->get_id() : 0;
816 $user_id = (int) get_current_user_id();
817
818 $eligible = $product_id > 0 && ! self::customer_has_subscription_for_product( $user_id, $product_id );
819
820 /**
821 * Filters whether the current customer is eligible for a
822 * subscription product's free trial.
823 *
824 * @since 2.4.0
825 *
826 * @param bool $eligible Eligibility resolved so far.
827 * @param int $product_id Product id.
828 * @param int $user_id Current user id (0 = guest).
829 */
830 return (bool) apply_filters( 'better_payment/woocommerce/trial_eligible', $eligible, $product_id, $user_id );
831 }
832
833 /**
834 * Whether a customer has (or had) a Better Payment subscription order
835 * containing the product. Scans the customer's subscription parent
836 * orders (STATUS_META present); any status counts — a cancelled trial
837 * still used up the trial. Cached per request; the scan is bounded and
838 * runs only for logged-in customers on trial products.
839 *
840 * @param int $user_id Customer user id (0 = guest, never matches).
841 * @param int $product_id Product (or variation) id.
842 * @return bool
843 */
844 public static function customer_has_subscription_for_product( $user_id, $product_id ) {
845 static $cache = array();
846
847 $user_id = (int) $user_id;
848 $product_id = (int) $product_id;
849
850 if ( $user_id < 1 || $product_id < 1 || ! function_exists( 'wc_get_orders' ) ) {
851 return false;
852 }
853
854 $key = $user_id . ':' . $product_id;
855
856 if ( isset( $cache[ $key ] ) ) {
857 return $cache[ $key ];
858 }
859
860 $orders = wc_get_orders(
861 array(
862 'customer_id' => $user_id,
863 'limit' => 100,
864 'type' => 'shop_order',
865 'return' => 'objects',
866 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 100), per-request cached, trial products only.
867 array(
868 'key' => self::STATUS_META,
869 'compare' => 'EXISTS',
870 ),
871 ),
872 )
873 );
874
875 $found = false;
876
877 if ( is_array( $orders ) ) {
878 foreach ( $orders as $order ) {
879 foreach ( self::order_subscription_items( $order ) as $item ) {
880 if ( (int) $item->get_product_id() === $product_id || (int) $item->get_variation_id() === $product_id ) {
881 $found = true;
882 break 2;
883 }
884 }
885 }
886 }
887
888 $cache[ $key ] = $found;
889
890 return $found;
891 }
892
893 /**
894 * One page of a customer's subscription parent orders, newest first —
895 * the My Account > Subscriptions list query. Ownership is enforced here
896 * (customer_id), so a caller can never page through someone else's
897 * subscriptions. Renewal orders never match: the subscription status
898 * meta only exists on parent orders.
899 *
900 * @param int $user_id Customer user id.
901 * @param int $page 1-based page number.
902 * @param int $per_page Orders per page.
903 * @return array { @type \WC_Order[] $orders, @type int $total, @type int $max_pages }
904 * @since 2.4.0
905 */
906 public static function customer_subscriptions( $user_id, $page = 1, $per_page = 10 ) {
907 $empty = array(
908 'orders' => array(),
909 'total' => 0,
910 'max_pages' => 0,
911 );
912
913 if ( ! function_exists( 'wc_get_orders' ) || (int) $user_id <= 0 ) {
914 return $empty;
915 }
916
917 $result = wc_get_orders(
918 array(
919 'customer_id' => (int) $user_id,
920 'type' => 'shop_order',
921 'limit' => max( 1, (int) $per_page ),
922 'paged' => max( 1, (int) $page ),
923 'paginate' => true,
924 'orderby' => 'date',
925 'order' => 'DESC',
926 'return' => 'objects',
927 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- the status meta only exists on subscription parent orders; this is the intended lookup.
928 array(
929 'key' => self::STATUS_META,
930 'compare' => 'EXISTS',
931 ),
932 ),
933 )
934 );
935
936 if ( ! is_object( $result ) || ! isset( $result->orders ) ) {
937 return $empty;
938 }
939
940 return array(
941 'orders' => is_array( $result->orders ) ? $result->orders : array(),
942 'total' => isset( $result->total ) ? (int) $result->total : 0,
943 'max_pages' => isset( $result->max_num_pages ) ? (int) $result->max_num_pages : 0,
944 );
945 }
946
947 /**
948 * The subscription's pending renewal invoice, when one is awaiting
949 * payment (a past_due recovery or a manual-policy renewal). Null when
950 * there is none, it is gone, or it no longer needs payment.
951 *
952 * @param mixed $parent Parent (subscription) order.
953 * @return \WC_Order|null
954 * @since 2.4.0
955 */
956 public static function pending_renewal_order( $parent ) {
957 if ( ! $parent instanceof \WC_Order || ! function_exists( 'wc_get_order' ) ) {
958 return null;
959 }
960
961 $pending_id = (int) $parent->get_meta( self::PENDING_RENEWAL_META );
962
963 if ( $pending_id <= 0 ) {
964 return null;
965 }
966
967 $pending = wc_get_order( $pending_id );
968
969 if ( $pending instanceof \WC_Order && $pending->needs_payment() ) {
970 return $pending;
971 }
972
973 return null;
974 }
975
976 /**
977 * Start scoping trial pricing to the totals calculation: while this
978 * filter is attached, get_price() answers 0 for trial-eligible
979 * subscription products, so an eligible customer's line totals compute
980 * to $0 — the first payment is charged when the trial ends (see
981 * activate_subscription()). Attached on `woocommerce_before_calculate_totals`
982 * and detached on `woocommerce_calculate_totals` /
983 * `woocommerce_after_calculate_totals`, so display price readers (the
984 * Store API's cart item prices, price HTML, the blocks cart's
985 * sale/"Save" detection) always see the product's real price. The
986 * product object is never mutated — mutating it (the pre-2.4.0 draft's
987 * set_price(0)) made the blocks cart render the trial as a fake
988 * 100%-off sale. Renewal orders (built server-side from the product's
989 * real price) are never affected.
990 *
991 * @return void
992 */
993 public static function add_trial_price_filter() {
994 add_filter( 'woocommerce_product_get_price', array( __CLASS__, 'zero_trial_price' ), 100, 2 );
995 }
996
997 /**
998 * Stop scoping trial pricing — the counterpart of
999 * add_trial_price_filter(), run as soon as totals calculation ends.
1000 *
1001 * @return void
1002 */
1003 public static function remove_trial_price_filter() {
1004 remove_filter( 'woocommerce_product_get_price', array( __CLASS__, 'zero_trial_price' ), 100 );
1005 }
1006
1007 /**
1008 * Filter callback for `woocommerce_product_get_price` while totals are
1009 * being calculated: 0 for a trial-eligible subscription product with a
1010 * trial configured, the real price for everything else.
1011 *
1012 * @param mixed $price The product price.
1013 * @param mixed $product The product.
1014 * @return mixed
1015 */
1016 public static function zero_trial_price( $price, $product ) {
1017 if ( ! self::product_is_subscription( $product ) ) {
1018 return $price;
1019 }
1020
1021 return self::trial_zeroed_price(
1022 $price,
1023 (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ),
1024 self::trial_eligible( $product )
1025 );
1026 }
1027
1028 /**
1029 * Pure: the price a subscription product's line calculates at, given
1030 * its trial configuration and the customer's eligibility. Only a
1031 * configured trial (>= 1 day) AND an eligible customer zero the price.
1032 *
1033 * @param mixed $price The product's real price.
1034 * @param mixed $trial_days Free trial duration in days.
1035 * @param bool $eligible Whether the customer may use the trial.
1036 * @return mixed 0 when the trial applies, the untouched price otherwise.
1037 */
1038 public static function trial_zeroed_price( $price, $trial_days, $eligible ) {
1039 if ( (int) $trial_days < 1 || ! $eligible ) {
1040 return $price;
1041 }
1042
1043 return 0;
1044 }
1045
1046 /**
1047 * Filter callback for `woocommerce_cart_needs_payment`: a subscription
1048 * cart always goes through payment, even at $0 — the gateway must run to
1049 * collect the card (setup-mode session) or to activate the subscription
1050 * (manual renewal policy). WooCommerce would otherwise complete a free
1051 * order without ever calling a gateway.
1052 *
1053 * @param mixed $needs WooCommerce's own answer.
1054 * @return bool
1055 */
1056 public static function filter_cart_needs_payment( $needs ) {
1057 return (bool) $needs || self::cart_contains_subscription();
1058 }
1059
1060 /**
1061 * Filter callback for `woocommerce_order_needs_payment` — the order-side
1062 * twin of filter_cart_needs_payment(), so the receipt page, the
1063 * return-URL verification and on_payment_confirmed() treat a pending $0
1064 * trial order as payable instead of skipping it.
1065 *
1066 * @param mixed $needs WooCommerce's own answer.
1067 * @param mixed $order The order.
1068 * @return bool
1069 */
1070 public static function filter_order_needs_payment( $needs, $order ) {
1071 if ( $needs || ! $order instanceof \WC_Order ) {
1072 return (bool) $needs;
1073 }
1074
1075 return self::needs_payment_override(
1076 (float) $order->get_total(),
1077 $order->has_status( array( 'pending', 'failed' ) ),
1078 self::order_contains_subscription( $order )
1079 );
1080 }
1081
1082 /**
1083 * The id of the ONLY gateway a subscription purchase may use.
1084 *
1085 * Resolves through Gateway::GATEWAY_ID when the gateway class is loadable.
1086 * Gateway extends WC_Payment_Gateway, so without WooCommerce the class
1087 * cannot load at all (the test environment runs WC-free) — the literal
1088 * fallback is pinned to the constant by SubscriptionsPureTest so the two
1089 * can never drift.
1090 *
1091 * @return string
1092 */
1093 public static function gateway_id() {
1094 if ( class_exists( 'WC_Payment_Gateway' ) ) {
1095 return Gateway::GATEWAY_ID;
1096 }
1097
1098 return 'better_payment_stripe';
1099 }
1100
1101 /**
1102 * Pure: reduce an available-gateways list to the Better Payment gateway
1103 * alone when the purchase requires it.
1104 *
1105 * When the Better Payment gateway is not itself in the list (disabled or
1106 * unconfigured), the result is deliberately EMPTY — WooCommerce then shows
1107 * its "no payment methods available" notice. Letting another gateway
1108 * through instead would sell a subscription that can never renew.
1109 *
1110 * @param mixed $gateways Available gateways (id => gateway).
1111 * @param bool $restrict Whether the purchase must use the BP gateway.
1112 * @return mixed
1113 */
1114 public static function filter_gateways_for_subscription( $gateways, $restrict ) {
1115 if ( ! $restrict || ! is_array( $gateways ) ) {
1116 return $gateways;
1117 }
1118
1119 return array_intersect_key( $gateways, array( self::gateway_id() => true ) );
1120 }
1121
1122 /**
1123 * Whether the purchase being paid for right now must use the BP gateway:
1124 * a subscription product in the cart, or — on the order-pay page — an
1125 * order that is a renewal invoice or contains a subscription product.
1126 *
1127 * @return bool
1128 */
1129 public static function checkout_requires_bp_gateway() {
1130 // Order-pay endpoint: manually paying an invoiced renewal (the
1131 // past_due recovery path) or a pending subscription order. The cart
1132 // is irrelevant here — the order alone decides.
1133 if ( function_exists( 'is_wc_endpoint_url' ) && is_wc_endpoint_url( 'order-pay' ) ) {
1134 $order = function_exists( 'wc_get_order' ) ? wc_get_order( absint( get_query_var( 'order-pay' ) ) ) : false;
1135
1136 if ( ! $order instanceof \WC_Order ) {
1137 return false;
1138 }
1139
1140 return (int) $order->get_meta( self::RENEWAL_PARENT_META ) > 0 || self::order_contains_subscription( $order );
1141 }
1142
1143 return self::cart_contains_subscription();
1144 }
1145
1146 /**
1147 * Filter callback for `woocommerce_available_payment_gateways`.
1148 *
1149 * @param mixed $gateways Available gateways (id => gateway).
1150 * @return mixed
1151 */
1152 public static function restrict_available_gateways( $gateways ) {
1153 // The WooCommerce settings screens run this filter too — the shop
1154 // owner must always see every gateway there.
1155 if ( is_admin() && ! wp_doing_ajax() ) {
1156 return $gateways;
1157 }
1158
1159 return self::filter_gateways_for_subscription( $gateways, self::checkout_requires_bp_gateway() );
1160 }
1161
1162 /**
1163 * Pure: add the off-session reuse instruction to a Checkout Session
1164 * request built by OrderHandler::build_session_request(). Stripe then
1165 * attaches the payment method to the session's customer for later
1166 * off-session charges.
1167 *
1168 * @param array $request Checkout Session request.
1169 * @return array
1170 */
1171 public static function add_off_session_setup( $request ) {
1172 if ( ! isset( $request['payment_intent_data'] ) || ! is_array( $request['payment_intent_data'] ) ) {
1173 $request['payment_intent_data'] = array();
1174 }
1175
1176 $request['payment_intent_data']['setup_future_usage'] = 'off_session';
1177
1178 return $request;
1179 }
1180
1181 /**
1182 * Pure: clamp a billing period to the supported set.
1183 *
1184 * @param mixed $period Raw value.
1185 * @return string
1186 */
1187 public static function sanitize_period( $period ) {
1188 return in_array( $period, self::PERIODS, true ) ? $period : 'month';
1189 }
1190
1191 /**
1192 * Pure: the next renewal timestamp for a schedule.
1193 *
1194 * @param int $interval Billing interval count.
1195 * @param string $period Billing period.
1196 * @param int $from Base timestamp (0 = now).
1197 * @return int
1198 */
1199 public static function next_payment_timestamp( $interval, $period, $from = 0 ) {
1200 $interval = max( 1, (int) $interval );
1201 $period = self::sanitize_period( $period );
1202 $from = (int) $from > 0 ? (int) $from : time();
1203
1204 return (int) strtotime( '+' . $interval . ' ' . $period, $from );
1205 }
1206
1207 /**
1208 * Pure: when a free trial ends — the timestamp of the FIRST payment on a
1209 * trial checkout. Nothing is paid at checkout, so the paid schedule
1210 * starts at trial end (NOT first cycle + trial).
1211 *
1212 * @param int $trial_days Free trial duration in days.
1213 * @param int $from Base timestamp (0 = now).
1214 * @return int
1215 */
1216 public static function trial_end_timestamp( $trial_days, $from = 0 ) {
1217 $from = (int) $from > 0 ? (int) $from : time();
1218
1219 return $from + ( max( 0, (int) $trial_days ) * DAY_IN_SECONDS );
1220 }
1221
1222 /**
1223 * Pure: whether a zero-total order must still go through payment so the
1224 * gateway can collect the card. Only pending/failed subscription orders
1225 * at $0 qualify — everything else keeps WooCommerce's own answer.
1226 *
1227 * @param float $total Order total.
1228 * @param bool $payable_status Order is in a payable status (pending/failed).
1229 * @param bool $contains_subscription Order contains a subscription product.
1230 * @return bool
1231 */
1232 public static function needs_payment_override( $total, $payable_status, $contains_subscription ) {
1233 return $contains_subscription && $payable_status && (float) $total < 0.01;
1234 }
1235
1236 /**
1237 * Pure: whether a renewal total must be charged through Stripe at all.
1238 *
1239 * A renewal can legitimately come due at $0 — create_renewal_order()
1240 * re-adds the parent's products at their CURRENT price, so a 100% sale
1241 * price (or a free plan) produces a zero-total renewal order. Stripe
1242 * rejects a PaymentIntent of amount 0 outright, so attempting the charge
1243 * turns a perfectly healthy subscription into past_due and emails the
1244 * customer an invoice for $0. The counterpart of the $0 branch
1245 * Gateway::process_payment() already has for the FIRST payment.
1246 *
1247 * @param float $total Renewal order total.
1248 * @return bool
1249 */
1250 public static function renewal_requires_charge( $total ) {
1251 return (float) $total >= 0.01;
1252 }
1253
1254 /**
1255 * Pure: build the Stripe setup-mode Checkout Session request for a $0
1256 * (free-trial) subscription checkout. A setup session collects and saves
1257 * a card without charging — the counterpart of
1258 * OrderHandler::build_session_request() for orders with nothing to pay.
1259 * No line_items / payment_intent_data (invalid in setup mode); the
1260 * SetupIntent carries the same metadata back-references the engine's
1261 * payment protocol uses.
1262 *
1263 * @param array $data {
1264 * @type string $bp_order_id Better Payment order id (stripe_xxx).
1265 * @type int $wc_order_id WooCommerce order id.
1266 * @type string $success_url Return URL on success.
1267 * @type string $cancel_url Return URL on cancel.
1268 * @type string $customer Stripe customer id to attach the card to.
1269 * }
1270 * @return array
1271 */
1272 public static function build_setup_session_request( $data ) {
1273 $metadata = array(
1274 'order_id' => (string) $data['bp_order_id'],
1275 'wc_order_id' => (string) $data['wc_order_id'],
1276 );
1277
1278 $request = array(
1279 'mode' => 'setup',
1280 'success_url' => (string) $data['success_url'],
1281 'cancel_url' => (string) $data['cancel_url'],
1282 'locale' => 'auto',
1283 'payment_method_types' => array( 'card' ),
1284 'client_reference_id' => (string) $data['wc_order_id'],
1285 'metadata' => $metadata,
1286 'setup_intent_data' => array(
1287 'metadata' => $metadata,
1288 ),
1289 );
1290
1291 // Without a customer the saved payment method would attach to
1292 // nothing and be unusable for off-session renewals.
1293 if ( ! empty( $data['customer'] ) ) {
1294 $request['customer'] = (string) $data['customer'];
1295 }
1296
1297 return $request;
1298 }
1299
1300 /**
1301 * Pure: human description of a schedule ("month", "every 3 months").
1302 *
1303 * @param int $interval Billing interval count.
1304 * @param string $period Billing period.
1305 * @return string
1306 */
1307 public static function describe_schedule( $interval, $period ) {
1308 $interval = max( 1, (int) $interval );
1309 $period = self::sanitize_period( $period );
1310
1311 $singular = array(
1312 'day' => __( 'day', 'better-payment' ),
1313 'week' => __( 'week', 'better-payment' ),
1314 'month' => __( 'month', 'better-payment' ),
1315 'year' => __( 'year', 'better-payment' ),
1316 );
1317 $plural = array(
1318 'day' => __( 'days', 'better-payment' ),
1319 'week' => __( 'weeks', 'better-payment' ),
1320 'month' => __( 'months', 'better-payment' ),
1321 'year' => __( 'years', 'better-payment' ),
1322 );
1323
1324 if ( 1 === $interval ) {
1325 return $singular[ $period ];
1326 }
1327
1328 return sprintf(
1329 /* translators: 1: interval count, 2: period plural (e.g. "months") */
1330 __( 'every %1$d %2$s', 'better-payment' ),
1331 $interval,
1332 $plural[ $period ]
1333 );
1334 }
1335
1336 /**
1337 * Pure: customer-facing label for a subscription status. Unknown values
1338 * fall back to a readable form of the raw status (underscores to
1339 * spaces) rather than an empty badge — a status whose key does not
1340 * match the label map (on_hold vs on-hold) renders exactly that.
1341 *
1342 * @param string $status Raw `_bp_subscription_status` meta value.
1343 * @return string
1344 * @since 2.4.0
1345 */
1346 public static function customer_status_label( $status ) {
1347 $status = strtolower( (string) $status );
1348
1349 $map = array(
1350 'active' => __( 'Active', 'better-payment' ),
1351 'past_due' => __( 'Payment past due', 'better-payment' ),
1352 'cancelled' => __( 'Cancelled', 'better-payment' ),
1353 'completed' => __( 'Completed', 'better-payment' ),
1354 );
1355
1356 if ( isset( $map[ $status ] ) ) {
1357 return $map[ $status ];
1358 }
1359
1360 return ucfirst( str_replace( '_', ' ', $status ) );
1361 }
1362
1363 /**
1364 * Pure: build the off-session PaymentIntent request for a renewal order.
1365 * Amount conversion mirrors OrderHandler::build_session_request() (minor
1366 * units), and metadata carries the same back-references the engine's
1367 * session protocol uses.
1368 *
1369 * @param array $data {
1370 * @type string $bp_order_id Better Payment order id (stripe_xxx).
1371 * @type int $wc_order_id Renewal WooCommerce order id.
1372 * @type int $parent_order_id Parent (subscription) order id.
1373 * @type string $order_number Renewal order display number.
1374 * @type float $amount Renewal total (major units).
1375 * @type string $currency ISO currency code.
1376 * @type string $customer Stripe customer id.
1377 * @type string $payment_method Stripe payment method id.
1378 * @type string $site_name Blog name for the description.
1379 * }
1380 * @return array
1381 */
1382 public static function build_renewal_intent_request( $data ) {
1383 $order_number = ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) ( isset( $data['wc_order_id'] ) ? $data['wc_order_id'] : '' );
1384 $site_name = ! empty( $data['site_name'] ) ? (string) $data['site_name'] : '';
1385
1386 /* translators: 1: order number, 2: site name */
1387 $description = trim( sprintf( __( 'Subscription renewal %1$s — %2$s', 'better-payment' ), '#' . $order_number, $site_name ), " \t" );
1388
1389 return array(
1390 'amount' => (int) round( (float) $data['amount'] * 100 ),
1391 'currency' => strtolower( (string) $data['currency'] ),
1392 'customer' => (string) $data['customer'],
1393 'payment_method' => (string) $data['payment_method'],
1394 'off_session' => 'true',
1395 'confirm' => 'true',
1396 'description' => $description,
1397 'metadata' => array(
1398 'order_id' => (string) $data['bp_order_id'],
1399 'wc_order_id' => (string) $data['wc_order_id'],
1400 'bp_subscription_parent' => (string) $data['parent_order_id'],
1401 ),
1402 );
1403 }
1404
1405 /**
1406 * Pure: build the Better Payment transaction row for a charged renewal.
1407 * Mirrors OrderHandler::build_transaction_data() so renewals appear in
1408 * the Better Payment transaction UI exactly like first payments (and
1409 * OrderHandler::extract_wc_order_id() resolves them the same way).
1410 *
1411 * @param array $data Same shape as build_renewal_intent_request().
1412 * @param object $intent Decoded Stripe PaymentIntent.
1413 * @return array
1414 */
1415 public static function build_renewal_transaction_data( $data, $intent ) {
1416 $form_fields_info = array(
1417 'wc_order_id' => (int) $data['wc_order_id'],
1418 'wc_order_number' => ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) $data['wc_order_id'],
1419 'source' => 'stripe',
1420 'amount' => (float) $data['amount'],
1421 'primary_email' => ! empty( $data['customer_email'] ) ? sanitize_email( $data['customer_email'] ) : '',
1422 'primary_name' => ! empty( $data['customer_name'] ) ? sanitize_text_field( $data['customer_name'] ) : '',
1423 'bp_subscription_parent' => (int) $data['parent_order_id'],
1424 );
1425
1426 // Stripe reports a settled PaymentIntent as 'succeeded' — a status
1427 // the transaction taxonomy (Classes\Helper v2 maps, DB counters, the
1428 // admin list's tag) does not know, and unknown statuses classify as
1429 // "Incomplete". Store 'paid' — the exact status the engine's
1430 // verified first payment writes — so renewals count as completed
1431 // everywhere. A non-settled status passes through verbatim (renew()
1432 // only records settled charges today; the passthrough keeps a future
1433 // caller honest rather than laundering failures into 'paid').
1434 $status = ! empty( $intent->status ) ? sanitize_text_field( $intent->status ) : 'succeeded';
1435 if ( 'succeeded' === $status ) {
1436 $status = 'paid';
1437 }
1438
1439 return array(
1440 'amount' => (float) $data['amount'],
1441 'order_id' => (string) $data['bp_order_id'],
1442 'payment_date' => current_time( 'mysql' ),
1443 'source' => 'stripe',
1444 'transaction_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '',
1445 'customer_info' => maybe_serialize( $intent ),
1446 'form_fields_info' => maybe_serialize( $form_fields_info ),
1447 'obj_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '',
1448 'status' => $status,
1449 'currency' => (string) $data['currency'],
1450 'referer' => 'woocommerce',
1451 'campaign_id' => '',
1452 );
1453 }
1454
1455 /* ---------------------------------------------------------------------
1456 * Activation + settlement
1457 * ------------------------------------------------------------------- */
1458
1459 /**
1460 * Consume the module's payment-complete hook. A first payment on a
1461 * subscription cart activates the subscription; a paid renewal order
1462 * (cron-charged or manually paid) advances the parent's schedule.
1463 *
1464 * @param mixed $order The paid order.
1465 * @param object|null $row The Better Payment transaction row.
1466 * @return void
1467 */
1468 public static function on_order_paid( $order, $row ) {
1469 if ( ! $order instanceof \WC_Order ) {
1470 return;
1471 }
1472
1473 $parent_id = (int) $order->get_meta( self::RENEWAL_PARENT_META );
1474
1475 if ( $parent_id > 0 ) {
1476 self::settle_renewal( $order, $parent_id );
1477 return;
1478 }
1479
1480 if ( '' !== (string) $order->get_meta( self::STATUS_META ) ) {
1481 return; // Already activated (duplicate confirmation).
1482 }
1483
1484 if ( ! self::order_contains_subscription( $order ) ) {
1485 return; // Ordinary one-off purchase — the common case.
1486 }
1487
1488 self::activate_subscription( $order, $row );
1489 }
1490
1491 /**
1492 * Activate a subscription on its parent order: snapshot the schedule,
1493 * save the reusable Stripe customer + payment method, set the first
1494 * renewal date.
1495 *
1496 * @param \WC_Order $order Parent order.
1497 * @param object|null $row Better Payment transaction row.
1498 * @return void
1499 */
1500 public static function activate_subscription( $order, $row ) {
1501 $items = self::order_subscription_items( $order );
1502
1503 if ( empty( $items ) ) {
1504 return;
1505 }
1506
1507 // The schedule (and the trial / cancellation policies) come from the
1508 // first subscription item, snapshotted at activation so a later
1509 // product edit never rewrites a customer's agreed terms (one
1510 // schedule per order — documented limitation).
1511 $product = $items[0]->get_product();
1512 $interval = $product ? max( 1, (int) $product->get_meta( self::PRODUCT_INTERVAL_META ) ) : 1;
1513 $period = self::sanitize_period( $product ? $product->get_meta( self::PRODUCT_PERIOD_META ) : 'month' );
1514 $trial_days = $product ? max( 0, (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ) ) : 0;
1515 $user_cancel = ( $product && 'yes' === $product->get_meta( self::USER_CANCEL_META ) ) ? 'yes' : 'no';
1516
1517 // The trial was applied iff the customer actually checked out at $0
1518 // for this item (zero_trial_price() zeroed the line during cart
1519 // totals calculation — the zeroed line IS the marker). A trial
1520 // product bought by an ineligible returning subscriber has a paid
1521 // line and renews on the plain schedule.
1522 $trial_applied = $trial_days > 0 && (float) $items[0]->get_total() < 0.01;
1523
1524 // Trial → the first payment is due when the trial ends (nothing was
1525 // paid at checkout). No trial → the paid first cycle just started,
1526 // next payment one cycle out.
1527 $next = $trial_applied
1528 ? self::trial_end_timestamp( $trial_days )
1529 : self::next_payment_timestamp( $interval, $period );
1530
1531 $harvested = self::harvest_payment_method( $order, $row );
1532
1533 $order->update_meta_data( self::STATUS_META, 'active' );
1534 $order->update_meta_data( self::INTERVAL_META, (string) $interval );
1535 $order->update_meta_data( self::PERIOD_META, $period );
1536 $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
1537 $order->update_meta_data( self::USER_CANCEL_META, $user_cancel );
1538 $order->update_meta_data( self::RENEWAL_COUNT_META, '0' );
1539
1540 if ( $trial_applied ) {
1541 $order->update_meta_data( self::TRIAL_APPLIED_META, 'yes' );
1542 }
1543
1544 $trial_note = '';
1545 if ( $trial_applied ) {
1546 $trial_note = ' ' . sprintf(
1547 /* translators: %d: number of free trial days */
1548 _n( '%d-day free trial — nothing was charged at checkout; the first payment is due when the trial ends.', '%d-day free trial — nothing was charged at checkout; the first payment is due when the trial ends.', $trial_days, 'better-payment' ),
1549 $trial_days
1550 );
1551 } elseif ( $trial_days > 0 ) {
1552 $trial_note = ' ' . __( 'The product offers a free trial but this customer already used one for it, so the regular schedule applies.', 'better-payment' );
1553 }
1554
1555 $order->add_order_note(
1556 sprintf(
1557 /* translators: 1: billing schedule, 2: next renewal date, 3: note about the saved payment method, 4: trial note (may be empty) */
1558 __( 'Better Payment: subscription activated — renews every %1$s. Next renewal: %2$s. %3$s%4$s', 'better-payment' ),
1559 self::describe_schedule( $interval, $period ),
1560 date_i18n( get_option( 'date_format' ), $next ),
1561 $harvested
1562 ? __( 'A reusable payment method was saved for automatic renewals.', 'better-payment' )
1563 : __( 'No reusable payment method could be saved — renewals will need manual payment.', 'better-payment' ),
1564 $trial_note
1565 )
1566 );
1567 $order->save();
1568
1569 // Relation table: the order that started the subscription.
1570 self::record_order_relation( $order->get_id(), $order, SubscriptionRelationModel::TYPE_NEW );
1571
1572 OrderHandler::log( 'Subscription activated on order #' . $order->get_id() . ' (every ' . $interval . ' ' . $period . ', next ' . gmdate( 'Y-m-d H:i:s', $next ) . ' UTC, reusable PM: ' . ( $harvested ? 'yes' : 'no' ) . ').' );
1573
1574 /**
1575 * Fires after a Better Payment subscription is activated on an order.
1576 *
1577 * @since 2.4.0
1578 *
1579 * @param \WC_Order $order The parent (subscription) order.
1580 */
1581 do_action( 'better_payment/woocommerce/subscription_activated', $order );
1582 }
1583
1584 /**
1585 * Record a row in the e-commerce subscription <-> order relation table
1586 * (source 'woo'). `$subscription_id` is the parent (subscription) order
1587 * id; `$order` is the related order — the parent itself for TYPE_NEW, a
1588 * renewal order for TYPE_RENEW. The model dedupes, so re-recording the
1589 * same relation is harmless.
1590 *
1591 * @param int $subscription_id Parent (subscription) order id.
1592 * @param mixed $order The related order (non-orders are rejected).
1593 * @param string $type SubscriptionRelationModel::TYPE_NEW|TYPE_RENEW.
1594 * @return void
1595 */
1596 public static function record_order_relation( $subscription_id, $order, $type ) {
1597 if ( ! $order instanceof \WC_Order ) {
1598 return;
1599 }
1600
1601 $items = self::order_subscription_items( $order );
1602 $order_item_id = ! empty( $items ) ? (int) $items[0]->get_id() : 0;
1603
1604 SubscriptionRelationModel::record(
1605 array(
1606 'subscription_id' => (int) $subscription_id,
1607 'order_id' => $order->get_id(),
1608 'order_item_id' => $order_item_id,
1609 'type' => $type,
1610 'source' => SubscriptionRelationModel::SOURCE_WOO,
1611 )
1612 );
1613 }
1614
1615 /**
1616 * Save the reusable Stripe customer + payment method from the verified
1617 * Checkout Session onto the order.
1618 *
1619 * @param \WC_Order $order Parent order.
1620 * @param object|null $row Better Payment transaction row (obj_id = session id).
1621 * @return bool Whether both identifiers are now stored.
1622 */
1623 public static function harvest_payment_method( $order, $row ) {
1624 if ( '' !== (string) $order->get_meta( self::CUSTOMER_META ) && '' !== (string) $order->get_meta( self::PAYMENT_METHOD_META ) ) {
1625 return true;
1626 }
1627
1628 $session_id = isset( $row->obj_id ) ? (string) $row->obj_id : '';
1629
1630 if ( '' === $session_id || 0 !== strpos( $session_id, 'cs_' ) ) {
1631 return false;
1632 }
1633
1634 $keys = StripeService::get_global_keys();
1635
1636 if ( empty( $keys['secret_key'] ) ) {
1637 return false;
1638 }
1639
1640 // Expand both intent kinds: a paid checkout saved the card on its
1641 // PaymentIntent; a $0 free-trial checkout saved it on the setup-mode
1642 // session's SetupIntent.
1643 $session = StripeService::retrieve_checkout_session( $session_id, $keys['secret_key'], array( 'payment_intent', 'setup_intent' ) );
1644
1645 if ( is_wp_error( $session ) ) {
1646 OrderHandler::log( 'Could not retrieve session ' . $session_id . ' to save the reusable payment method for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
1647 return false;
1648 }
1649
1650 $customer = '';
1651 if ( ! empty( $session->customer ) ) {
1652 $customer = is_object( $session->customer ) && ! empty( $session->customer->id ) ? (string) $session->customer->id : (string) $session->customer;
1653 }
1654
1655 $payment_method = '';
1656 if ( ! empty( $session->payment_intent ) && is_object( $session->payment_intent ) && ! empty( $session->payment_intent->payment_method ) ) {
1657 $pm = $session->payment_intent->payment_method;
1658 $payment_method = is_object( $pm ) && ! empty( $pm->id ) ? (string) $pm->id : (string) $pm;
1659 }
1660
1661 if ( '' === $payment_method && ! empty( $session->setup_intent ) && is_object( $session->setup_intent ) && ! empty( $session->setup_intent->payment_method ) ) {
1662 $pm = $session->setup_intent->payment_method;
1663 $payment_method = is_object( $pm ) && ! empty( $pm->id ) ? (string) $pm->id : (string) $pm;
1664 }
1665
1666 if ( '' === $customer || '' === $payment_method ) {
1667 return false;
1668 }
1669
1670 $order->update_meta_data( self::CUSTOMER_META, sanitize_text_field( $customer ) );
1671 $order->update_meta_data( self::PAYMENT_METHOD_META, sanitize_text_field( $payment_method ) );
1672
1673 return true;
1674 }
1675
1676 /**
1677 * A renewal order was paid — advance the parent's schedule and (re)set
1678 * it to active. This is also the past_due recovery path: manually paying
1679 * the invoiced renewal order lands here through the same hook.
1680 *
1681 * @param \WC_Order $renewal_order The paid renewal order.
1682 * @param int $parent_id Parent (subscription) order id.
1683 * @return void
1684 */
1685 public static function settle_renewal( $renewal_order, $parent_id ) {
1686 $parent = function_exists( 'wc_get_order' ) ? wc_get_order( $parent_id ) : false;
1687
1688 if ( ! $parent ) {
1689 OrderHandler::log( 'Renewal order #' . $renewal_order->get_id() . ' paid but parent order #' . $parent_id . ' was not found.', 'error' );
1690 return;
1691 }
1692
1693 if ( 'cancelled' === (string) $parent->get_meta( self::STATUS_META ) ) {
1694 $parent->add_order_note(
1695 sprintf(
1696 /* translators: %d: renewal order id */
1697 __( 'Better Payment: renewal order #%d was paid but the subscription is cancelled — the schedule was not advanced.', 'better-payment' ),
1698 $renewal_order->get_id()
1699 )
1700 );
1701 $parent->save();
1702 return;
1703 }
1704
1705 $renewal_count = (int) $parent->get_meta( self::RENEWAL_COUNT_META ) + 1;
1706
1707 $parent->update_meta_data( self::RENEWAL_COUNT_META, (string) $renewal_count );
1708 $parent->update_meta_data( self::LAST_PAYMENT_META, (string) time() );
1709 // A paid renewal clears the outstanding manual-renewal invoice guard
1710 // (no-op for auto-charged renewals).
1711 $parent->delete_meta_data( self::PENDING_RENEWAL_META );
1712
1713 $interval = max( 1, (int) $parent->get_meta( self::INTERVAL_META ) );
1714 $period = self::sanitize_period( $parent->get_meta( self::PERIOD_META ) );
1715 $next = self::next_payment_timestamp( $interval, $period );
1716
1717 $parent->update_meta_data( self::STATUS_META, 'active' );
1718 $parent->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
1719 $parent->add_order_note(
1720 sprintf(
1721 /* translators: 1: renewal order id, 2: next renewal date */
1722 __( 'Better Payment: renewal order #%1$d paid. Next renewal: %2$s.', 'better-payment' ),
1723 $renewal_order->get_id(),
1724 date_i18n( get_option( 'date_format' ), $next )
1725 )
1726 );
1727 $parent->save();
1728
1729 OrderHandler::log( 'Renewal order #' . $renewal_order->get_id() . ' settled for subscription order #' . $parent_id . '; next renewal ' . gmdate( 'Y-m-d H:i:s', $next ) . ' UTC.' );
1730
1731 /**
1732 * Fires after a subscription renewal payment settles.
1733 *
1734 * @since 2.4.0
1735 *
1736 * @param \WC_Order $parent The parent (subscription) order.
1737 * @param \WC_Order $renewal_order The paid renewal order.
1738 */
1739 do_action( 'better_payment/woocommerce/subscription_renewed', $parent, $renewal_order );
1740 }
1741
1742 /* ---------------------------------------------------------------------
1743 * Renewal processing (cron)
1744 * ------------------------------------------------------------------- */
1745
1746 /**
1747 * Charge every due subscription. Runs hourly via WP-Cron.
1748 *
1749 * @return void
1750 */
1751 public static function process_due_subscriptions() {
1752 if ( ! function_exists( 'wc_get_orders' ) || ! StripeService::is_configured() ) {
1753 return;
1754 }
1755
1756 $orders = wc_get_orders(
1757 array(
1758 'limit' => 20,
1759 'type' => 'shop_order',
1760 'status' => array( 'wc-processing', 'wc-completed' ),
1761 'return' => 'objects',
1762 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 20), hourly cron.
1763 array(
1764 'key' => self::STATUS_META,
1765 'value' => 'active',
1766 ),
1767 array(
1768 'key' => self::NEXT_PAYMENT_META,
1769 'value' => time(),
1770 'compare' => '<=',
1771 'type' => 'NUMERIC',
1772 ),
1773 ),
1774 )
1775 );
1776
1777 if ( empty( $orders ) || ! is_array( $orders ) ) {
1778 return;
1779 }
1780
1781 foreach ( $orders as $order ) {
1782 self::renew( $order );
1783 }
1784 }
1785
1786 /**
1787 * Create and charge one renewal for a due subscription.
1788 *
1789 * On success the renewal order is marked paid, the payment is recorded in
1790 * the Better Payment transactions table, and the parent's schedule
1791 * advances (through the shared payment-complete hook). On failure the
1792 * subscription goes past_due: auto-charging stops and the customer is
1793 * emailed the pending renewal order to pay manually.
1794 *
1795 * @param \WC_Order $parent Parent (subscription) order.
1796 * @return void
1797 */
1798 public static function renew( $parent ) {
1799 // Idempotency guard — a slow Stripe call must not let an overlapping
1800 // cron run double-charge the same subscription.
1801 $lock_key = 'bp_wc_sub_renew_' . $parent->get_id();
1802
1803 if ( get_transient( $lock_key ) ) {
1804 return;
1805 }
1806
1807 set_transient( $lock_key, 1, 10 * MINUTE_IN_SECONDS );
1808
1809 // Manual renewal policy — the site-wide Renewal Mode / Automatic
1810 // Stripe Charging settings or the customer's own auto-renew opt-out:
1811 // invoice the renewal instead of charging it.
1812 if ( ! self::auto_renewal_enabled_for( $parent ) ) {
1813 self::request_manual_renewal( $parent );
1814 return;
1815 }
1816
1817 $customer = (string) $parent->get_meta( self::CUSTOMER_META );
1818 $payment_method = (string) $parent->get_meta( self::PAYMENT_METHOD_META );
1819
1820 if ( '' === $customer || '' === $payment_method ) {
1821 // No stored card to charge — invoice the renewal for manual
1822 // payment instead of stalling the subscription in past_due with
1823 // nothing for the customer to pay. (This is also how a
1824 // subscription sold while renewals were manual keeps renewing
1825 // after the site switches back to automatic.)
1826 self::request_manual_renewal( $parent );
1827 return;
1828 }
1829
1830 $renewal = self::create_renewal_order( $parent );
1831
1832 if ( ! $renewal instanceof \WC_Order ) {
1833 OrderHandler::log( 'Could not create a renewal order for subscription order #' . $parent->get_id() . '.', 'error' );
1834 return;
1835 }
1836
1837 // A $0 renewal — the product is on a 100% sale, or priced free.
1838 // create_renewal_order() re-adds the parent's products at their
1839 // CURRENT price, so this is reachable on any subscription whose price
1840 // has since been discounted to nothing. Stripe rejects a
1841 // PaymentIntent of amount 0, so charging it would fail, call
1842 // mark_past_due() and invoice the customer $0 — turning a healthy
1843 // subscription into a broken one over a discount the site owner
1844 // chose. Complete it directly instead, exactly as
1845 // Gateway::process_payment() completes a $0 FIRST payment, and let
1846 // the shared completion contract advance the schedule.
1847 if ( ! self::renewal_requires_charge( (float) $renewal->get_total() ) ) {
1848 $renewal->add_order_note( __( 'Better Payment: subscription renewal total is zero — nothing to charge. The renewal was completed without contacting Stripe.', 'better-payment' ) );
1849 $renewal->payment_complete();
1850 $renewal->save();
1851
1852 OrderHandler::log( 'Renewal order #' . $renewal->get_id() . ' for subscription order #' . $parent->get_id() . ' completed without a charge (zero total).' );
1853
1854 // No Better Payment transaction row is written: nothing was paid
1855 // and there is no Stripe object to record. Same precedent as the
1856 // $0 first payment under a manual renewal policy
1857 // (Gateway::process_payment()).
1858 do_action( 'better_payment/woocommerce/payment_complete', $renewal, null );
1859 return;
1860 }
1861
1862 $keys = StripeService::get_global_keys();
1863 $bp_order_id = \Better_Payment\Lite\Classes\Handler::new_order_id( 'stripe' );
1864
1865 $data = array(
1866 'bp_order_id' => $bp_order_id,
1867 'wc_order_id' => $renewal->get_id(),
1868 'parent_order_id' => $parent->get_id(),
1869 'order_number' => $renewal->get_order_number(),
1870 'amount' => (float) $renewal->get_total(),
1871 'currency' => $renewal->get_currency(),
1872 'customer' => $customer,
1873 'payment_method' => $payment_method,
1874 'customer_email' => $renewal->get_billing_email(),
1875 'customer_name' => trim( $renewal->get_billing_first_name() . ' ' . $renewal->get_billing_last_name() ),
1876 'site_name' => get_bloginfo( 'name' ),
1877 );
1878
1879 $intent = StripeService::create_payment_intent( self::build_renewal_intent_request( $data ), $keys['secret_key'] );
1880
1881 if ( is_wp_error( $intent ) || empty( $intent->status ) || 'succeeded' !== $intent->status ) {
1882 $reason = is_wp_error( $intent )
1883 ? $intent->get_error_message()
1884 : sprintf(
1885 /* translators: %s: Stripe PaymentIntent status */
1886 __( 'the charge did not succeed (status: %s)', 'better-payment' ),
1887 ! empty( $intent->status ) ? sanitize_text_field( $intent->status ) : 'unknown'
1888 );
1889
1890 self::mark_past_due( $parent, $renewal, $reason );
1891
1892 /**
1893 * Fires after an automatic subscription renewal charge fails.
1894 *
1895 * @since 2.4.0
1896 *
1897 * @param \WC_Order $parent The parent (subscription) order.
1898 * @param \WC_Order $renewal The pending renewal order.
1899 * @param string $reason Failure reason.
1900 */
1901 do_action( 'better_payment/woocommerce/subscription_renewal_failed', $parent, $renewal, $reason );
1902 return;
1903 }
1904
1905 // Record the payment through the engine's own writer, then mark the
1906 // renewal order paid.
1907 $transaction_row_id = Handler::payment_create( self::build_renewal_transaction_data( $data, $intent ) );
1908
1909 $renewal->update_meta_data( '_bp_transaction_id', (string) $transaction_row_id );
1910 $renewal->update_meta_data( '_bp_payment_id', $bp_order_id );
1911 $renewal->update_meta_data( '_bp_gateway', 'stripe' );
1912 $renewal->update_meta_data( '_bp_payment_status', sanitize_text_field( $intent->status ) );
1913 $renewal->update_meta_data( '_bp_paid_at', current_time( 'mysql' ) );
1914 $renewal->add_order_note(
1915 sprintf(
1916 /* translators: 1: Stripe PaymentIntent id, 2: Better Payment record id */
1917 __( 'Better Payment: subscription renewal charged off-session. Transaction ID: %1$s (Better Payment record #%2$d).', 'better-payment' ),
1918 sanitize_text_field( $intent->id ),
1919 (int) $transaction_row_id
1920 )
1921 );
1922 $renewal->payment_complete( sanitize_text_field( $intent->id ) );
1923 $renewal->save();
1924
1925 OrderHandler::log( 'Renewal order #' . $renewal->get_id() . ' charged for subscription order #' . $parent->get_id() . ' (' . $bp_order_id . ').' );
1926
1927 $row = $transaction_row_id ? DB::get_transaction( (int) $transaction_row_id ) : null;
1928
1929 // Same completion contract as a verified first payment — this is what
1930 // advances the parent's schedule (see on_order_paid()).
1931 do_action( 'better_payment/woocommerce/payment_complete', $renewal, $row );
1932 }
1933
1934 /**
1935 * Create the pending renewal order for a subscription: the parent's
1936 * subscription line items at their CURRENT product price, billed to the
1937 * parent's billing address through this gateway.
1938 *
1939 * @param \WC_Order $parent Parent (subscription) order.
1940 * @return \WC_Order|null
1941 */
1942 public static function create_renewal_order( $parent ) {
1943 if ( ! function_exists( 'wc_create_order' ) ) {
1944 return null;
1945 }
1946
1947 $items = self::order_subscription_items( $parent );
1948
1949 if ( empty( $items ) ) {
1950 return null;
1951 }
1952
1953 $renewal = wc_create_order(
1954 array(
1955 'customer_id' => $parent->get_customer_id(),
1956 'status' => 'wc-pending',
1957 'created_via' => 'better_payment_subscription',
1958 )
1959 );
1960
1961 if ( is_wp_error( $renewal ) ) {
1962 return null;
1963 }
1964
1965 $added = 0;
1966
1967 foreach ( $items as $item ) {
1968 $product = $item->get_product();
1969
1970 if ( ! $product ) {
1971 continue;
1972 }
1973
1974 $renewal->add_product( $product, $item->get_quantity() );
1975 $added++;
1976 }
1977
1978 if ( 0 === $added ) {
1979 $renewal->delete( true );
1980 return null;
1981 }
1982
1983 $renewal->set_address( $parent->get_address( 'billing' ), 'billing' );
1984 $renewal->set_payment_method( Gateway::GATEWAY_ID );
1985 $renewal->set_payment_method_title( $parent->get_payment_method_title() );
1986 $renewal->update_meta_data( self::RENEWAL_PARENT_META, (string) $parent->get_id() );
1987 $renewal->update_meta_data( self::CUSTOMER_META, (string) $parent->get_meta( self::CUSTOMER_META ) );
1988 $renewal->update_meta_data( self::PAYMENT_METHOD_META, (string) $parent->get_meta( self::PAYMENT_METHOD_META ) );
1989 $renewal->add_order_note(
1990 sprintf(
1991 /* translators: %d: parent order id */
1992 __( 'Better Payment: subscription renewal order for order #%d.', 'better-payment' ),
1993 $parent->get_id()
1994 )
1995 );
1996 $renewal->calculate_totals();
1997 $renewal->save();
1998
1999 // Relation table: renewal order -> its parent subscription.
2000 self::record_order_relation( $parent->get_id(), $renewal, SubscriptionRelationModel::TYPE_RENEW );
2001
2002 /**
2003 * Fires after a subscription renewal order is created (before it is
2004 * charged).
2005 *
2006 * @since 2.4.0
2007 *
2008 * @param \WC_Order $renewal The pending renewal order.
2009 * @param \WC_Order $parent The parent (subscription) order.
2010 */
2011 do_action( 'better_payment/woocommerce/subscription_renewal_order_created', $renewal, $parent );
2012
2013 return $renewal;
2014 }
2015
2016 /**
2017 * A renewal could not be charged: stop auto-charging (past_due) and, when
2018 * a pending renewal order exists, email it to the customer to pay
2019 * manually. Paying it reactivates the subscription (settle_renewal()).
2020 *
2021 * @param \WC_Order $parent Parent (subscription) order.
2022 * @param \WC_Order|null $renewal Pending renewal order, when one was created.
2023 * @param string $reason Failure reason for the order note/log.
2024 * @return void
2025 */
2026 public static function mark_past_due( $parent, $renewal, $reason ) {
2027 $parent->update_meta_data( self::STATUS_META, 'past_due' );
2028 $parent->add_order_note(
2029 sprintf(
2030 /* translators: %s: failure reason */
2031 __( 'Better Payment: automatic renewal failed — %s. Automatic charging is paused until the pending renewal order is paid.', 'better-payment' ),
2032 $reason
2033 )
2034 );
2035 $parent->save();
2036
2037 OrderHandler::log( 'Subscription order #' . $parent->get_id() . ' set to past_due: ' . $reason, 'error' );
2038
2039 if ( $renewal instanceof \WC_Order ) {
2040 $renewal->add_order_note(
2041 sprintf(
2042 /* translators: %s: failure reason */
2043 __( 'Better Payment: the automatic charge failed — %s. The order was invoiced to the customer for manual payment.', 'better-payment' ),
2044 $reason
2045 )
2046 );
2047 $renewal->save();
2048
2049 self::send_customer_invoice( $renewal );
2050 }
2051 }
2052
2053 /**
2054 * A renewal is due but must be paid manually — because of the site-wide
2055 * manual renewal policy, the customer's auto-renew opt-out, or a missing
2056 * stored payment method. Creates the pending renewal order once and
2057 * emails it to the customer; paying it advances the schedule through
2058 * settle_renewal() exactly like an auto-charged renewal.
2059 *
2060 * Hourly-cron safe: while the previously invoiced renewal order is still
2061 * awaiting payment, nothing new is created or sent.
2062 *
2063 * @param \WC_Order $parent Parent (subscription) order.
2064 * @return void
2065 */
2066 public static function request_manual_renewal( $parent ) {
2067 $pending_id = (int) $parent->get_meta( self::PENDING_RENEWAL_META );
2068
2069 if ( $pending_id > 0 && function_exists( 'wc_get_order' ) ) {
2070 $pending = wc_get_order( $pending_id );
2071
2072 if ( $pending instanceof \WC_Order && $pending->needs_payment() ) {
2073 return; // Already invoiced — waiting on the customer.
2074 }
2075 }
2076
2077 $renewal = self::create_renewal_order( $parent );
2078
2079 if ( ! $renewal instanceof \WC_Order ) {
2080 OrderHandler::log( 'Could not create a manual renewal order for subscription order #' . $parent->get_id() . '.', 'error' );
2081 return;
2082 }
2083
2084 $renewal->add_order_note( __( 'Better Payment: manual subscription renewal — invoiced to the customer for payment.', 'better-payment' ) );
2085 $renewal->save();
2086
2087 $parent->update_meta_data( self::PENDING_RENEWAL_META, (string) $renewal->get_id() );
2088 $parent->add_order_note(
2089 sprintf(
2090 /* translators: %d: renewal order id */
2091 __( 'Better Payment: renewal due — renewal order #%d was invoiced to the customer for manual payment.', 'better-payment' ),
2092 $renewal->get_id()
2093 )
2094 );
2095 $parent->save();
2096
2097 self::send_customer_invoice( $renewal );
2098
2099 OrderHandler::log( 'Manual renewal order #' . $renewal->get_id() . ' invoiced for subscription order #' . $parent->get_id() . '.' );
2100
2101 /**
2102 * Fires after a manual renewal order is created and invoiced.
2103 *
2104 * @since 2.4.0
2105 *
2106 * @param \WC_Order $parent The parent (subscription) order.
2107 * @param \WC_Order $renewal The pending renewal order.
2108 */
2109 do_action( 'better_payment/woocommerce/subscription_manual_renewal_requested', $parent, $renewal );
2110 }
2111
2112 /**
2113 * Email a renewal order to its customer through WooCommerce's own
2114 * customer-invoice email (it carries the pay link).
2115 *
2116 * @param \WC_Order $renewal Renewal order.
2117 * @return void
2118 */
2119 public static function send_customer_invoice( $renewal ) {
2120 if ( ! function_exists( 'WC' ) ) {
2121 return;
2122 }
2123
2124 $emails = WC()->mailer()->get_emails();
2125 $invoice = isset( $emails['WC_Email_Customer_Invoice'] ) ? $emails['WC_Email_Customer_Invoice'] : null;
2126
2127 if ( $invoice instanceof \WC_Email_Customer_Invoice ) {
2128 $invoice->trigger( $renewal->get_id(), $renewal );
2129 }
2130 }
2131
2132 /* ---------------------------------------------------------------------
2133 * Customer auto-renewal toggle (My Account)
2134 * ------------------------------------------------------------------- */
2135
2136 /**
2137 * Automatic-renewal on/off control, rendered on the My Account
2138 * single-subscription view only ({@see MyAccount::render_view()}, context
2139 * 'subscription'), where the form carries a return flag so the handler
2140 * redirects back to the subscription page. It is deliberately not hooked
2141 * onto any order-details screen — see register().
2142 *
2143 * Rendered only when the Customer Auto-Renew Control setting allows it,
2144 * automatic renewal is enabled site-wide (otherwise every renewal is
2145 * manual and the toggle would do nothing), a reusable payment method is
2146 * stored, the subscription is live, and the viewer owns the order.
2147 *
2148 * @param mixed $order The order being viewed.
2149 * @param string $context 'subscription' (the only caller); '' omits the
2150 * return flag, redirecting to the order page.
2151 * @return void
2152 */
2153 public static function render_auto_renew_toggle( $order, $context = '' ) {
2154 if ( ! $order instanceof \WC_Order ) {
2155 return;
2156 }
2157
2158 if ( ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2159 return;
2160 }
2161
2162 if ( 'yes' !== self::setting( 'auto_renewal_toggle' ) || ! self::should_save_payment_method() ) {
2163 return;
2164 }
2165
2166 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2167 return;
2168 }
2169
2170 if ( '' === (string) $order->get_meta( self::CUSTOMER_META ) || '' === (string) $order->get_meta( self::PAYMENT_METHOD_META ) ) {
2171 return;
2172 }
2173
2174 $auto_on = 'no' !== (string) $order->get_meta( self::AUTO_RENEW_META );
2175
2176 ?>
2177 <form method="post" class="bp-subscription-auto-renew">
2178 <?php wp_nonce_field( 'bp_auto_renew_' . $order->get_id(), 'bp_auto_renew_nonce' ); ?>
2179 <input type="hidden" name="bp_auto_renew_order" value="<?php echo esc_attr( (string) $order->get_id() ); ?>">
2180 <input type="hidden" name="bp_auto_renew_value" value="<?php echo esc_attr( $auto_on ? 'no' : 'yes' ); ?>">
2181 <?php if ( 'subscription' === $context ) : ?>
2182 <input type="hidden" name="bp_return_subscription" value="1">
2183 <?php endif; ?>
2184 <p>
2185 <?php
2186 echo esc_html(
2187 $auto_on
2188 ? __( 'Automatic renewal is on — renewals are charged to your saved payment method.', 'better-payment' )
2189 : __( 'Automatic renewal is off — you will be emailed an invoice when each renewal is due.', 'better-payment' )
2190 );
2191 ?>
2192 </p>
2193 <button type="submit" class="button">
2194 <?php echo esc_html( $auto_on ? __( 'Turn off automatic renewal', 'better-payment' ) : __( 'Turn on automatic renewal', 'better-payment' ) ); ?>
2195 </button>
2196 </form>
2197 <?php
2198 }
2199
2200 /**
2201 * Handle the auto-renewal toggle submit. Nonce-guarded and restricted to
2202 * the order's own customer on a live subscription.
2203 *
2204 * @return void
2205 */
2206 public static function maybe_handle_auto_renew_toggle() {
2207 if ( empty( $_POST['bp_auto_renew_order'] ) || ! function_exists( 'wc_get_order' ) ) {
2208 return;
2209 }
2210
2211 $order_id = absint( wp_unslash( $_POST['bp_auto_renew_order'] ) );
2212 $nonce = isset( $_POST['bp_auto_renew_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bp_auto_renew_nonce'] ) ) : '';
2213
2214 if ( ! $order_id || ! wp_verify_nonce( $nonce, 'bp_auto_renew_' . $order_id ) ) {
2215 return;
2216 }
2217
2218 $order = wc_get_order( $order_id );
2219
2220 if ( ! $order || ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2221 return;
2222 }
2223
2224 if ( 'yes' !== self::setting( 'auto_renewal_toggle' ) ) {
2225 return;
2226 }
2227
2228 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2229 return;
2230 }
2231
2232 $enable = isset( $_POST['bp_auto_renew_value'] ) && 'yes' === sanitize_text_field( wp_unslash( $_POST['bp_auto_renew_value'] ) );
2233
2234 $order->update_meta_data( self::AUTO_RENEW_META, $enable ? 'yes' : 'no' );
2235 $order->add_order_note(
2236 $enable
2237 ? __( 'Better Payment: the customer turned automatic renewal ON — due renewals are charged to the saved payment method.', 'better-payment' )
2238 : __( 'Better Payment: the customer turned automatic renewal OFF — due renewals are invoiced for manual payment.', 'better-payment' )
2239 );
2240 $order->save();
2241
2242 OrderHandler::log( 'Customer set auto renewal ' . ( $enable ? 'on' : 'off' ) . ' for subscription order #' . $order->get_id() . '.' );
2243
2244 if ( function_exists( 'wc_add_notice' ) ) {
2245 wc_add_notice(
2246 $enable
2247 ? __( 'Automatic renewal is now on.', 'better-payment' )
2248 : __( 'Automatic renewal is now off. We will email you an invoice when a renewal is due.', 'better-payment' )
2249 );
2250 }
2251
2252 wp_safe_redirect( self::customer_action_redirect( $order ) );
2253 exit;
2254 }
2255
2256 /**
2257 * Where a customer-action handler sends the customer afterwards: back to
2258 * the My Account subscription view when the form came from there (the
2259 * `bp_return_subscription` flag — a flag, never a client-supplied URL),
2260 * otherwise the view-order page the shared forms have always used.
2261 * Only read after the action's nonce has been verified.
2262 *
2263 * @param \WC_Order $order Parent (subscription) order.
2264 * @return string
2265 */
2266 protected static function customer_action_redirect( $order ) {
2267 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- callers verify their own per-order nonce before this runs.
2268 if ( ! empty( $_POST['bp_return_subscription'] ) ) {
2269 $url = MyAccount::view_url( $order->get_id() );
2270
2271 if ( '' !== $url ) {
2272 return $url;
2273 }
2274 }
2275
2276 return $order->get_view_order_url();
2277 }
2278
2279 /* ---------------------------------------------------------------------
2280 * Cancellation
2281 * ------------------------------------------------------------------- */
2282
2283 /**
2284 * Offer the cancel action on parent orders with a live subscription.
2285 *
2286 * @param array $actions Order actions.
2287 * @param mixed $order The order being edited (newer WooCommerce passes it).
2288 * @return array
2289 */
2290 public static function register_order_action( $actions, $order = null ) {
2291 if ( ! $order instanceof \WC_Order && isset( $GLOBALS['theorder'] ) && $GLOBALS['theorder'] instanceof \WC_Order ) {
2292 $order = $GLOBALS['theorder'];
2293 }
2294
2295 if ( $order instanceof \WC_Order && in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2296 $actions['bp_cancel_subscription'] = __( 'Cancel Better Payment Subscription', 'better-payment' );
2297 }
2298
2299 return $actions;
2300 }
2301
2302 /**
2303 * Handle the admin order action.
2304 *
2305 * @param \WC_Order $order Parent (subscription) order.
2306 * @return void
2307 */
2308 public static function handle_cancel_action( $order ) {
2309 self::cancel( $order, '', 'admin' );
2310 }
2311
2312 /**
2313 * Customer-facing cancel button, rendered on the My Account
2314 * single-subscription view only ({@see MyAccount::render_view()}, context
2315 * 'subscription'), where the form carries a return flag so the handler
2316 * redirects back to the subscription page. It is deliberately not hooked
2317 * onto any order-details screen — see register().
2318 *
2319 * Rendered only when the product allowed user cancellation (snapshotted
2320 * on the parent order at activation), the subscription is live, and the
2321 * viewer is the order's own customer.
2322 *
2323 * @param mixed $order The order being viewed.
2324 * @param string $context 'subscription' (the only caller); '' omits the
2325 * return flag, redirecting to the order page.
2326 * @return void
2327 */
2328 public static function render_customer_cancel_button( $order, $context = '' ) {
2329 if ( ! $order instanceof \WC_Order ) {
2330 return;
2331 }
2332
2333 if ( ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2334 return;
2335 }
2336
2337 if ( 'yes' !== (string) $order->get_meta( self::USER_CANCEL_META ) ) {
2338 return;
2339 }
2340
2341 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2342 return;
2343 }
2344
2345 ?>
2346 <form method="post" class="bp-subscription-cancel">
2347 <?php wp_nonce_field( 'bp_cancel_subscription_' . $order->get_id(), 'bp_cancel_subscription_nonce' ); ?>
2348 <input type="hidden" name="bp_cancel_subscription_order" value="<?php echo esc_attr( (string) $order->get_id() ); ?>">
2349 <?php if ( 'subscription' === $context ) : ?>
2350 <input type="hidden" name="bp_return_subscription" value="1">
2351 <?php endif; ?>
2352 <button type="submit" class="button" onclick="return confirm( '<?php echo esc_js( __( 'Cancel this subscription? No further renewals will be charged.', 'better-payment' ) ); ?>' );">
2353 <?php esc_html_e( 'Cancel subscription', 'better-payment' ); ?>
2354 </button>
2355 </form>
2356 <?php
2357 }
2358
2359 /**
2360 * Handle the customer cancellation submit. Nonce-guarded and restricted
2361 * to the order's own customer on an order whose product allowed it.
2362 *
2363 * @return void
2364 */
2365 public static function maybe_handle_customer_cancel() {
2366 if ( empty( $_POST['bp_cancel_subscription_order'] ) || ! function_exists( 'wc_get_order' ) ) {
2367 return;
2368 }
2369
2370 $order_id = absint( wp_unslash( $_POST['bp_cancel_subscription_order'] ) );
2371 $nonce = isset( $_POST['bp_cancel_subscription_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bp_cancel_subscription_nonce'] ) ) : '';
2372
2373 if ( ! $order_id || ! wp_verify_nonce( $nonce, 'bp_cancel_subscription_' . $order_id ) ) {
2374 return;
2375 }
2376
2377 $order = wc_get_order( $order_id );
2378
2379 if ( ! $order || ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2380 return;
2381 }
2382
2383 if ( 'yes' !== (string) $order->get_meta( self::USER_CANCEL_META ) ) {
2384 return;
2385 }
2386
2387 self::cancel( $order, __( 'Better Payment: subscription cancelled by the customer — no further automatic renewals will be charged.', 'better-payment' ), 'customer' );
2388
2389 if ( function_exists( 'wc_add_notice' ) ) {
2390 wc_add_notice( __( 'Your subscription has been cancelled.', 'better-payment' ) );
2391 }
2392
2393 wp_safe_redirect( self::customer_action_redirect( $order ) );
2394 exit;
2395 }
2396
2397 /**
2398 * Cancel a subscription. Terminal: renewals stop; already-created pending
2399 * renewal orders are left for the shop owner to keep or cancel.
2400 *
2401 * @param mixed $order Parent (subscription) order.
2402 * @param string $note Optional order-note override (e.g. the customer-
2403 * initiated cancellation message).
2404 * @param string $actor Who is cancelling: 'customer' | 'admin' | ''
2405 * (unknown — no actor stamp, the details view shows
2406 * no Cancelled By row).
2407 * @return void
2408 */
2409 public static function cancel( $order, $note = '', $actor = '' ) {
2410 if ( ! $order instanceof \WC_Order ) {
2411 return;
2412 }
2413
2414 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2415 return;
2416 }
2417
2418 if ( in_array( $actor, array( 'customer', 'admin' ), true ) ) {
2419 $order->update_meta_data( self::CANCELLED_BY_TYPE_META, $actor );
2420 $order->update_meta_data( self::CANCELLED_BY_META, (string) get_current_user_id() );
2421 }
2422
2423 $order->update_meta_data( self::STATUS_META, 'cancelled' );
2424 $order->add_order_note( '' !== $note ? $note : __( 'Better Payment: subscription cancelled — no further automatic renewals will be charged.', 'better-payment' ) );
2425 $order->save();
2426
2427 OrderHandler::log( 'Subscription cancelled on order #' . $order->get_id() . '.' );
2428
2429 /**
2430 * Fires after a Better Payment subscription is cancelled.
2431 *
2432 * @since 2.4.0
2433 *
2434 * @param \WC_Order $order The parent (subscription) order.
2435 */
2436 do_action( 'better_payment/woocommerce/subscription_cancelled', $order );
2437 }
2438
2439 /**
2440 * Reactivate a cancelled (or past_due) subscription from the admin
2441 * Subscriptions tab. Sets the status back to active; when the stored
2442 * next-payment date already elapsed while the subscription was
2443 * cancelled, a fresh one is scheduled a full cycle from now — otherwise
2444 * the hourly cron would charge the customer the moment reactivation
2445 * saved.
2446 *
2447 * @param mixed $order Parent (subscription) order.
2448 * @param string $note Optional order-note override.
2449 * @return void
2450 */
2451 public static function reactivate( $order, $note = '' ) {
2452 if ( ! $order instanceof \WC_Order ) {
2453 return;
2454 }
2455
2456 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'cancelled', 'past_due' ), true ) ) {
2457 return;
2458 }
2459
2460 $next = (int) $order->get_meta( self::NEXT_PAYMENT_META );
2461
2462 if ( $next <= time() ) {
2463 $interval = max( 1, (int) $order->get_meta( self::INTERVAL_META ) );
2464 $period = self::sanitize_period( $order->get_meta( self::PERIOD_META ) );
2465 $next = self::next_payment_timestamp( $interval, $period );
2466 $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
2467 }
2468
2469 $order->update_meta_data( self::STATUS_META, 'active' );
2470 // The subscription is live again — a later cancellation must record
2471 // its own actor, never inherit this one's.
2472 $order->delete_meta_data( self::CANCELLED_BY_TYPE_META );
2473 $order->delete_meta_data( self::CANCELLED_BY_META );
2474 $order->add_order_note(
2475 '' !== $note ? $note : sprintf(
2476 /* translators: %s: next renewal date */
2477 __( 'Better Payment: subscription reactivated. Next renewal: %s.', 'better-payment' ),
2478 date_i18n( get_option( 'date_format' ), $next )
2479 )
2480 );
2481 $order->save();
2482
2483 OrderHandler::log( 'Subscription reactivated on order #' . $order->get_id() . ' (next renewal ' . gmdate( 'Y-m-d H:i:s', $next ) . ' UTC).' );
2484
2485 /**
2486 * Fires after a Better Payment subscription is reactivated.
2487 *
2488 * @since 2.4.0
2489 *
2490 * @param \WC_Order $order The parent (subscription) order.
2491 */
2492 do_action( 'better_payment/woocommerce/subscription_reactivated', $order );
2493 }
2494
2495 /* ---------------------------------------------------------------------
2496 * Subscriber role sync
2497 * ------------------------------------------------------------------- */
2498
2499 /**
2500 * Subscription activated → give the customer the Subscriber Default
2501 * Role.
2502 *
2503 * @param mixed $order Parent (subscription) order.
2504 * @return void
2505 */
2506 public static function assign_active_role( $order ) {
2507 self::swap_customer_role( $order, self::setting( 'active_role' ), true );
2508 }
2509
2510 /**
2511 * Subscription cancelled/completed → give the customer the Subscriber
2512 * Inactive Role, unless they still hold another live subscription.
2513 *
2514 * @param mixed $order Parent (subscription) order.
2515 * @return void
2516 */
2517 public static function assign_inactive_role( $order ) {
2518 if ( ! $order instanceof \WC_Order ) {
2519 return;
2520 }
2521
2522 if ( self::customer_has_other_live_subscription( $order ) ) {
2523 return;
2524 }
2525
2526 self::swap_customer_role( $order, self::setting( 'inactive_role' ), false );
2527 }
2528
2529 /**
2530 * Pure: whether a user's current roles forbid the subscription role
2531 * swap. Site staff must never be demoted to a subscriber role by buying
2532 * or cancelling a subscription — set_role() REPLACES the user's roles.
2533 *
2534 * @param mixed $roles The user's current role slugs.
2535 * @return bool
2536 */
2537 public static function user_role_is_protected( $roles ) {
2538 /**
2539 * Filters the roles the subscription role sync refuses to replace.
2540 *
2541 * @since 2.4.0
2542 *
2543 * @param string[] $protected Protected role slugs.
2544 */
2545 $protected = apply_filters(
2546 'better_payment/woocommerce/role_sync_protected_roles',
2547 array( 'administrator', 'editor', 'shop_manager' )
2548 );
2549
2550 return (bool) array_intersect( (array) $roles, (array) $protected );
2551 }
2552
2553 /**
2554 * Replace the order's customer role for the subscription lifecycle.
2555 * No-ops on guest orders, unknown/blank target roles, protected users,
2556 * and users already holding the target role.
2557 *
2558 * @param mixed $order Parent (subscription) order.
2559 * @param string $role Target role slug (from the settings).
2560 * @param bool $active Whether the swap is for an activation (for the note).
2561 * @return void
2562 */
2563 public static function swap_customer_role( $order, $role, $active ) {
2564 if ( ! $order instanceof \WC_Order ) {
2565 return;
2566 }
2567
2568 $role = (string) $role;
2569
2570 if ( '' === $role || null === get_role( $role ) ) {
2571 return; // Unknown role — never assign a role that doesn't exist.
2572 }
2573
2574 $user_id = (int) $order->get_customer_id();
2575
2576 if ( $user_id <= 0 ) {
2577 return; // Guest order — no account to update.
2578 }
2579
2580 $user = get_user_by( 'id', $user_id );
2581
2582 if ( ! $user || in_array( $role, (array) $user->roles, true ) || self::user_role_is_protected( $user->roles ) ) {
2583 return;
2584 }
2585
2586 $user->set_role( $role );
2587
2588 $note_template = $active
2589 /* translators: %s: role slug */
2590 ? __( 'Better Payment: customer role set to "%s" — subscription active.', 'better-payment' )
2591 /* translators: %s: role slug */
2592 : __( 'Better Payment: customer role set to "%s" — subscription ended.', 'better-payment' );
2593
2594 $order->add_order_note( sprintf( $note_template, $role ) );
2595 $order->save();
2596
2597 OrderHandler::log( 'Customer #' . $user_id . ' role set to ' . $role . ' for subscription order #' . $order->get_id() . '.' );
2598 }
2599
2600 /**
2601 * Whether the order's customer holds another live (active or past_due)
2602 * subscription besides this one.
2603 *
2604 * @param \WC_Order $order Parent (subscription) order.
2605 * @return bool
2606 */
2607 public static function customer_has_other_live_subscription( $order ) {
2608 if ( ! function_exists( 'wc_get_orders' ) ) {
2609 return false;
2610 }
2611
2612 $customer_id = (int) $order->get_customer_id();
2613
2614 if ( $customer_id <= 0 ) {
2615 return false;
2616 }
2617
2618 $others = wc_get_orders(
2619 array(
2620 'limit' => 1,
2621 'type' => 'shop_order',
2622 'customer_id' => $customer_id,
2623 'exclude' => array( $order->get_id() ),
2624 'return' => 'ids',
2625 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 1), event-driven.
2626 array(
2627 'key' => self::STATUS_META,
2628 'value' => array( 'active', 'past_due' ),
2629 'compare' => 'IN',
2630 ),
2631 ),
2632 )
2633 );
2634
2635 return is_array( $others ) && count( $others ) > 0;
2636 }
2637
2638 /* ---------------------------------------------------------------------
2639 * Admin list (React admin → Subscriptions tab)
2640 * ------------------------------------------------------------------- */
2641
2642 /**
2643 * Flat display row for one subscription on the admin Subscriptions tab.
2644 *
2645 * Reads the parent (subscription) order and its `_bp_subscription_*`
2646 * meta. Safe to call whether or not WooCommerce is active — returns null
2647 * when WooCommerce (or the order) is unavailable, and the caller keeps
2648 * its un-hydrated base row.
2649 *
2650 * Dates are returned as site-local `Y-m-d H:i:s` strings — the same shape
2651 * as the transactions table's `payment_date` — so the React admin formats
2652 * them with the one date helper it already has.
2653 *
2654 * @param int $subscription_id Parent (subscription) order id.
2655 * @return array|null
2656 * @since 2.4.0
2657 */
2658 public static function admin_list_row( $subscription_id ) {
2659 if ( ! function_exists( 'wc_get_order' ) ) {
2660 return null;
2661 }
2662
2663 $order = wc_get_order( absint( $subscription_id ) );
2664
2665 if ( ! $order instanceof \WC_Order ) {
2666 return null;
2667 }
2668
2669 $items = self::order_subscription_items( $order );
2670 $first_item = ! empty( $items ) ? current( $items ) : null;
2671 $product_name = $first_item instanceof \WC_Order_Item_Product ? $first_item->get_name() : '';
2672
2673 $customer_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
2674 if ( '' === $customer_name ) {
2675 $customer_name = trim( (string) $order->get_formatted_billing_full_name() );
2676 }
2677
2678 $next_payment = (int) $order->get_meta( self::NEXT_PAYMENT_META );
2679 $status = (string) $order->get_meta( self::STATUS_META );
2680 $created = $order->get_date_created();
2681
2682 return array(
2683 'customer_name' => $customer_name,
2684 'customer_email' => (string) $order->get_billing_email(),
2685 'product_name' => (string) $product_name,
2686 'amount' => (float) $order->get_total(),
2687 'currency' => (string) $order->get_currency(),
2688 'interval' => max( 1, (int) $order->get_meta( self::INTERVAL_META ) ),
2689 'period' => self::sanitize_period( $order->get_meta( self::PERIOD_META ) ),
2690 'status' => $status,
2691 'renewal_count' => (int) $order->get_meta( self::RENEWAL_COUNT_META ),
2692 // No next charge on a terminal subscription — send '' so the UI
2693 // shows an em dash instead of a stale date.
2694 'next_payment' => ( $next_payment > 0 && in_array( $status, array( 'active', 'past_due' ), true ) )
2695 ? wp_date( 'Y-m-d H:i:s', $next_payment )
2696 : '',
2697 'start_date' => $created ? $created->date( 'Y-m-d H:i:s' ) : '',
2698 'order_edit_url' => (string) $order->get_edit_order_url(),
2699 );
2700 }
2701
2702 /**
2703 * Light status lookup for one subscription — just the raw
2704 * `_bp_subscription_status` meta, lowercased, with no row hydration.
2705 * Used by the admin Subscriptions tab's summary counts, which need
2706 * every subscription's status but nothing else from the order.
2707 *
2708 * @param int $subscription_id Parent (subscription) order id.
2709 * @return string Raw status ('' when WooCommerce or the order is gone).
2710 * @since 2.4.0
2711 */
2712 public static function admin_status( $subscription_id ) {
2713 if ( ! function_exists( 'wc_get_order' ) ) {
2714 return '';
2715 }
2716
2717 $order = wc_get_order( absint( $subscription_id ) );
2718
2719 if ( ! $order instanceof \WC_Order ) {
2720 return '';
2721 }
2722
2723 return strtolower( (string) $order->get_meta( self::STATUS_META ) );
2724 }
2725
2726 /**
2727 * Pure: the status actions the admin Subscriptions details view may
2728 * offer for a subscription in the given status. Status transitions are
2729 * the ONLY thing editable — everything else is read-only.
2730 *
2731 * `completed` is terminal (the subscription's schedule ended;
2732 * reactivating would restart a schedule the customer finished paying),
2733 * and an empty/unknown status (un-hydrated row, integration gone)
2734 * offers nothing.
2735 *
2736 * @param string $status Raw `_bp_subscription_status` meta value.
2737 * @return string[] Zero or more of 'cancel' | 'reactivate'.
2738 */
2739 public static function available_admin_actions( $status ) {
2740 $map = array(
2741 'active' => array( 'cancel' ),
2742 'past_due' => array( 'cancel', 'reactivate' ),
2743 'cancelled' => array( 'reactivate' ),
2744 );
2745
2746 $status = strtolower( (string) $status );
2747
2748 return isset( $map[ $status ] ) ? $map[ $status ] : array();
2749 }
2750
2751 /**
2752 * Extended display data for one subscription on the admin details view:
2753 * the list row plus the fields only the details page shows. Returns null
2754 * when WooCommerce (or the order) is unavailable, same contract as
2755 * admin_list_row().
2756 *
2757 * @param int $subscription_id Parent (subscription) order id.
2758 * @return array|null
2759 * @since 2.4.0
2760 */
2761 public static function admin_detail( $subscription_id ) {
2762 $row = self::admin_list_row( $subscription_id );
2763
2764 if ( null === $row ) {
2765 return null;
2766 }
2767
2768 $order = wc_get_order( absint( $subscription_id ) );
2769
2770 if ( ! $order instanceof \WC_Order ) {
2771 return null;
2772 }
2773
2774 // Effective state, not the raw AUTO_RENEW_META flag: the site-wide
2775 // renewal policy AND the customer's own preference — the same test
2776 // renew() applies. Reporting the flag alone showed "On" while a
2777 // manual policy meant every renewal was actually invoiced.
2778 $row['auto_renew'] = self::auto_renewal_enabled_for( $order ) ? 'yes' : 'no';
2779 $row['available_actions'] = self::available_admin_actions( $row['status'] );
2780
2781 // When the most recent renewal payment settled — '' until the first
2782 // renewal, and the UI drops the row entirely rather than showing an
2783 // empty value (same treatment next_payment gets on a terminal
2784 // subscription).
2785 $last_payment = self::last_payment_timestamp( $order );
2786 $row['last_payment'] = $last_payment > 0 ? wp_date( 'Y-m-d H:i:s', $last_payment ) : '';
2787
2788 // Who cancelled the subscription. Only reported while the status is
2789 // actually cancelled AND cancel() stamped an actor — pre-stamp
2790 // cancellations have no answer, and the UI drops the row rather than
2791 // guessing.
2792 $row['cancelled_by_type'] = '';
2793 $row['cancelled_by_name'] = '';
2794
2795 if ( 'cancelled' === $row['status'] ) {
2796 $actor = (string) $order->get_meta( self::CANCELLED_BY_TYPE_META );
2797
2798 if ( in_array( $actor, array( 'customer', 'admin' ), true ) ) {
2799 $row['cancelled_by_type'] = $actor;
2800 $row['cancelled_by_name'] = self::cancelled_by_name( $order, $actor );
2801 }
2802 }
2803
2804 // Billing & Shipping card. Plain-text lines, not WooCommerce's
2805 // formatted-address HTML — the React admin renders text children
2806 // only, never markup.
2807 $row['billing_address'] = self::address_lines( $order->get_formatted_billing_address() );
2808 $row['shipping_address'] = self::address_lines( $order->get_formatted_shipping_address() );
2809 $row['billing_phone'] = (string) $order->get_billing_phone();
2810
2811 return $row;
2812 }
2813
2814 /**
2815 * Display name of the user who cancelled the subscription. Resolved live
2816 * from the stamped user id so a renamed account reads current; when that
2817 * user is gone (or the stamp predates a user id), a customer
2818 * cancellation still has the order's own billing name to fall back on —
2819 * an admin one does not, and returns '' (the UI shows the actor tag
2820 * alone).
2821 *
2822 * @param \WC_Order $order Parent (subscription) order.
2823 * @param string $actor 'customer' | 'admin'.
2824 * @return string
2825 * @since 2.4.0
2826 */
2827 public static function cancelled_by_name( $order, $actor ) {
2828 $user_id = (int) $order->get_meta( self::CANCELLED_BY_META );
2829 $user = $user_id > 0 ? get_userdata( $user_id ) : false;
2830
2831 if ( $user && '' !== trim( (string) $user->display_name ) ) {
2832 return (string) $user->display_name;
2833 }
2834
2835 if ( 'customer' === $actor ) {
2836 $billing = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
2837
2838 if ( '' !== $billing ) {
2839 return $billing;
2840 }
2841 }
2842
2843 return '';
2844 }
2845
2846 /**
2847 * When the subscription's most recent renewal payment settled, as a UNIX
2848 * timestamp — 0 when it has never renewed (the initial checkout is the
2849 * Started date, not a renewal payment). Reads the LAST_PAYMENT_META stamp
2850 * settle_renewal() writes; subscriptions renewed before the stamp existed
2851 * fall back to the newest paid renewal order's paid date.
2852 *
2853 * @param \WC_Order $order Parent (subscription) order.
2854 * @return int
2855 * @since 2.4.0
2856 */
2857 public static function last_payment_timestamp( $order ) {
2858 $stamped = (int) $order->get_meta( self::LAST_PAYMENT_META );
2859
2860 if ( $stamped > 0 ) {
2861 return $stamped;
2862 }
2863
2864 if ( (int) $order->get_meta( self::RENEWAL_COUNT_META ) < 1 ) {
2865 return 0;
2866 }
2867
2868 $renewals = wc_get_orders(
2869 array(
2870 'limit' => 1,
2871 'type' => 'shop_order',
2872 'status' => array( 'wc-processing', 'wc-completed' ),
2873 'orderby' => 'date',
2874 'order' => 'DESC',
2875 'meta_key' => self::RENEWAL_PARENT_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- bounded (limit 1), details-view only.
2876 'meta_value' => (string) $order->get_id(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- see above.
2877 )
2878 );
2879
2880 $renewal = is_array( $renewals ) && ! empty( $renewals ) ? current( $renewals ) : null;
2881
2882 if ( ! $renewal instanceof \WC_Order ) {
2883 return 0;
2884 }
2885
2886 $paid = $renewal->get_date_paid();
2887
2888 if ( ! $paid ) {
2889 $paid = $renewal->get_date_created();
2890 }
2891
2892 return $paid ? $paid->getTimestamp() : 0;
2893 }
2894
2895 /**
2896 * Pure: a WooCommerce formatted address (lines joined with <br/>) as an
2897 * array of plain-text lines. Empty/whitespace lines are dropped; an
2898 * empty/absent address yields an empty array (the UI renders an em dash).
2899 *
2900 * @param mixed $formatted The formatted address HTML ('' when unset).
2901 * @return string[]
2902 * @since 2.4.0
2903 */
2904 public static function address_lines( $formatted ) {
2905 if ( ! is_string( $formatted ) || '' === trim( $formatted ) ) {
2906 return array();
2907 }
2908
2909 $lines = preg_split( '#<br\s*/?>#i', $formatted );
2910 $clean = array();
2911
2912 foreach ( (array) $lines as $line ) {
2913 $line = trim( wp_strip_all_tags( (string) $line ) );
2914
2915 if ( '' !== $line ) {
2916 $clean[] = $line;
2917 }
2918 }
2919
2920 return $clean;
2921 }
2922
2923 /**
2924 * Display row for one related order on the admin details view's
2925 * Related Orders list. Returns null when WooCommerce (or the order) is
2926 * unavailable — the caller keeps its un-hydrated base row.
2927 *
2928 * @param int $order_id Related (parent or renewal) order id.
2929 * @return array|null
2930 * @since 2.4.0
2931 */
2932 public static function admin_order_row( $order_id ) {
2933 if ( ! function_exists( 'wc_get_order' ) ) {
2934 return null;
2935 }
2936
2937 $order = wc_get_order( absint( $order_id ) );
2938
2939 if ( ! $order instanceof \WC_Order ) {
2940 return null;
2941 }
2942
2943 $created = $order->get_date_created();
2944 $status = (string) $order->get_status();
2945
2946 return array(
2947 'order_number' => (string) $order->get_order_number(),
2948 'date' => $created ? $created->date( 'Y-m-d H:i:s' ) : '',
2949 'status' => $status,
2950 'status_label' => function_exists( 'wc_get_order_status_name' ) ? wc_get_order_status_name( $status ) : ucfirst( $status ),
2951 'total' => (float) $order->get_total(),
2952 'currency' => (string) $order->get_currency(),
2953 'edit_url' => (string) $order->get_edit_order_url(),
2954 );
2955 }
2956
2957 /**
2958 * Perform an admin status action (cancel | reactivate) on a
2959 * subscription. Used by the admin Subscriptions REST endpoint.
2960 *
2961 * @param int $subscription_id Parent (subscription) order id.
2962 * @param string $action 'cancel' or 'reactivate'.
2963 * @return true|\WP_Error
2964 * @since 2.4.0
2965 */
2966 public static function admin_status_action( $subscription_id, $action ) {
2967 if ( ! function_exists( 'wc_get_order' ) ) {
2968 return new \WP_Error( 'woocommerce_inactive', __( 'WooCommerce is not active, so this subscription cannot be managed.', 'better-payment' ), array( 'status' => 400 ) );
2969 }
2970
2971 $order = wc_get_order( absint( $subscription_id ) );
2972
2973 if ( ! $order instanceof \WC_Order ) {
2974 return new \WP_Error( 'subscription_not_found', __( 'Subscription order not found.', 'better-payment' ), array( 'status' => 404 ) );
2975 }
2976
2977 $status = (string) $order->get_meta( self::STATUS_META );
2978
2979 if ( ! in_array( $action, self::available_admin_actions( $status ), true ) ) {
2980 return new \WP_Error( 'action_not_available', __( 'This action is not available for the subscription\'s current status.', 'better-payment' ), array( 'status' => 400 ) );
2981 }
2982
2983 if ( 'cancel' === $action ) {
2984 self::cancel( $order, __( 'Better Payment: subscription cancelled from the Better Payment admin — no further automatic renewals will be charged.', 'better-payment' ), 'admin' );
2985 } else {
2986 self::reactivate( $order );
2987 }
2988
2989 return true;
2990 }
2991 }
2992