PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 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 All 68 releases
better-payment / includes / WooCommerce / Subscriptions.php

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

3,690 lines 154.0 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 * Renewal-order meta: 'yes' once this renewal payment has advanced the
163 * parent's schedule. settle_renewal() advances the schedule by a full
164 * cycle, so it must run exactly once per renewal order — the payment can
165 * reach it from the gateway return AND from the order-status bridge
166 * (maybe_complete_from_status()), and a second pass would hand the
167 * customer a free billing period.
168 */
169 const RENEWAL_SETTLED_META = '_bp_subscription_renewal_settled';
170
171 /**
172 * Parent-order meta: the subscription's agreed line prices, snapshotted
173 * at activation (see renewal_line_snapshot()). Renewals are billed from
174 * it, so editing the product's price never silently re-prices a running
175 * subscription — the same principle the schedule snapshot already
176 * follows. Absent on subscriptions that were already running when this
177 * shipped, which keep renewing at the product's current price exactly as
178 * before.
179 */
180 const PRICE_SNAPSHOT_META = '_bp_subscription_price_snapshot';
181
182 /**
183 * User meta: the role this module granted the customer for the
184 * subscription lifecycle. The role sync removes only what it granted, so
185 * a customer's own roles (a membership plugin's role, `customer`, a
186 * custom staff role) survive both activation and cancellation.
187 */
188 const GRANTED_ROLE_META = '_bp_subscription_granted_role';
189
190 /**
191 * Parent-order meta: 'no' when the customer switched automatic renewal
192 * off from their My Account page ('' / 'yes' = renew automatically).
193 * Only meaningful while automatic renewal is enabled site-wide.
194 */
195 const AUTO_RENEW_META = '_bp_subscription_auto_renew';
196
197 /**
198 * Parent-order meta: id of the manual-renewal order that was invoiced
199 * and is still awaiting payment. Guards the hourly cron from stacking a
200 * new invoice on every run while one is already outstanding.
201 */
202 const PENDING_RENEWAL_META = '_bp_subscription_pending_renewal';
203
204 /**
205 * Option-key prefix of the E-commerce > Subscription settings (Better
206 * Payment → Settings → E-commerce → Subscription; defaults in
207 * Admin\DB::default_settings()).
208 */
209 const SETTING_PREFIX = 'better_payment_settings_ecommerce_subscription_';
210
211 /**
212 * Cron hook that processes due renewals.
213 */
214 const CRON_HOOK = 'better_payment_woocommerce_subscriptions_due';
215
216 /**
217 * Supported billing periods (strtotime-compatible units).
218 *
219 * @var string[]
220 */
221 const PERIODS = array( 'day', 'week', 'month', 'year' );
222
223 /**
224 * Wire the feature. Called from Loader::register(), i.e. only when
225 * WooCommerce is active.
226 *
227 * @return void
228 */
229 public static function register() {
230 // Product settings (admin product edit screen, General tab).
231 add_action( 'woocommerce_product_options_general_product_data', array( __CLASS__, 'render_product_fields' ) );
232 add_action( 'woocommerce_admin_process_product_object', array( __CLASS__, 'save_product_fields' ) );
233
234 // Storefront price label ("$10.00 / month").
235 add_filter( 'woocommerce_get_price_html', array( __CLASS__, 'price_html_suffix' ), 10, 2 );
236
237 // Free trial: an eligible customer checks out at $0 (the first
238 // payment is charged when the trial ends), and the $0 checkout must
239 // still run through this gateway so the card is collected for
240 // renewals — WooCommerce skips payment entirely on zero-total
241 // carts/orders unless told otherwise.
242 //
243 // The $0 is scoped to totals CALCULATION only (a get_price filter
244 // added before calculate_totals and removed right after): line
245 // totals come out $0 while the
246 // product's stored/display price stays real. Mutating the cart
247 // item's price instead (set_price(0)) made every price reader for
248 // the rest of the request see 0 against a real regular price, so
249 // the blocks cart displayed the trial as a fake sale — "~~100.00$~~
250 // 0.00$ / Save 100.00$" — on a product that is not discounted at all.
251 add_action( 'woocommerce_before_calculate_totals', array( __CLASS__, 'add_trial_price_filter' ), 20 );
252 add_action( 'woocommerce_calculate_totals', array( __CLASS__, 'remove_trial_price_filter' ) );
253 add_action( 'woocommerce_after_calculate_totals', array( __CLASS__, 'remove_trial_price_filter' ) );
254 add_filter( 'woocommerce_cart_needs_payment', array( __CLASS__, 'filter_cart_needs_payment' ) );
255 add_filter( 'woocommerce_order_needs_payment', array( __CLASS__, 'filter_order_needs_payment' ), 10, 2 );
256
257 // Custom Add to Cart button text (single product page + shop loop).
258 add_filter( 'woocommerce_product_single_add_to_cart_text', array( __CLASS__, 'filter_add_to_cart_text' ), 10, 2 );
259 add_filter( 'woocommerce_product_add_to_cart_text', array( __CLASS__, 'filter_add_to_cart_text' ), 10, 2 );
260
261 // One subscription per order: a subscription is checked out on its
262 // own, never beside a second subscription and never beside a one-off
263 // product. Enforced in two layers — refuse the add,
264 // and re-check the whole cart on the cart/checkout pages for the
265 // compositions add-to-cart validation cannot see. Both hooks are
266 // shared by the classic and blocks (Store API) surfaces.
267 add_filter( 'woocommerce_add_to_cart_validation', array( __CLASS__, 'validate_add_to_cart_composition' ), 10, 2 );
268 add_action( 'woocommerce_check_cart_items', array( __CLASS__, 'enforce_cart_composition' ) );
269
270 // Activation + renewal settlement. Both the first payment and every
271 // renewal payment (cron-charged or manually paid) converge on the
272 // module's payment-complete hook.
273 add_action( 'better_payment/woocommerce/payment_complete', array( __CLASS__, 'on_order_paid' ), 10, 2 );
274
275 // Orders that become paid without passing through the gateway return
276 // — an admin marking a subscription order complete, an offline
277 // payment on an invoiced renewal — reach the same completion
278 // contract here. Both handlers below it are idempotent, so an order
279 // that already went through the return path changes nothing.
280 add_action( 'woocommerce_order_status_processing', array( __CLASS__, 'maybe_complete_from_status' ), 20 );
281 add_action( 'woocommerce_order_status_completed', array( __CLASS__, 'maybe_complete_from_status' ), 20 );
282
283 // Renewal scheduler.
284 add_action( self::CRON_HOOK, array( __CLASS__, 'process_due_subscriptions' ) );
285
286 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
287 wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK );
288 }
289
290 register_deactivation_hook( BETTER_PAYMENT_FILE, array( __CLASS__, 'unschedule' ) );
291
292 // Admin: cancel action on the parent order edit screen.
293 add_filter( 'woocommerce_order_actions', array( __CLASS__, 'register_order_action' ), 10, 2 );
294 add_action( 'woocommerce_order_action_bp_cancel_subscription', array( __CLASS__, 'handle_cancel_action' ) );
295
296 // Customer: cancel button. NOT hooked on
297 // `woocommerce_order_details_after_order_table` — that hook fires on
298 // both the My Account view-order page and the order-received
299 // (thank-you) page, so the button turned up on a plain order receipt
300 // and on every order-details screen. Subscription management lives in
301 // one place: My Account > Subscriptions, whose single-subscription
302 // view calls the renderer directly (MyAccount::render_view()). Only
303 // the POST handler is hooked here.
304 add_action( 'template_redirect', array( __CLASS__, 'maybe_handle_customer_cancel' ) );
305
306 // Checkout: a subscription purchase can only go through this gateway.
307 // Any other gateway (COD, bank transfer, another Stripe plugin, …)
308 // cannot save the reusable off-session payment method renewals are
309 // charged with, so the subscription would activate and then never
310 // renew. Covers the cart/checkout (classic + blocks, which share this
311 // filter) and the order-pay page (invoiced renewals + pending
312 // subscription orders).
313 add_filter( 'woocommerce_available_payment_gateways', array( __CLASS__, 'restrict_available_gateways' ) );
314
315 // …and when that restriction leaves nothing available (the gateway is
316 // switched off or Stripe is not configured), say so instead of
317 // letting WooCommerce blame the customer's country.
318 add_filter( 'woocommerce_no_available_payment_methods_message', array( __CLASS__, 'no_payment_methods_message' ) );
319
320 // Customer Auto-Renew Control setting: customer-facing automatic-renewal
321 // on/off. Rendered only from My Account > Subscriptions, exactly like
322 // the cancel button above — never on an order-details screen.
323 add_action( 'template_redirect', array( __CLASS__, 'maybe_handle_auto_renew_toggle' ) );
324
325 // Subscriber Default/Inactive Role settings: keep the customer's
326 // role in sync with their subscription's lifecycle.
327 add_action( 'better_payment/woocommerce/subscription_activated', array( __CLASS__, 'assign_active_role' ) );
328 add_action( 'better_payment/woocommerce/subscription_cancelled', array( __CLASS__, 'assign_inactive_role' ) );
329 add_action( 'better_payment/woocommerce/subscription_completed', array( __CLASS__, 'assign_inactive_role' ) );
330 }
331
332 /**
333 * Clear the renewal cron event on plugin deactivation.
334 *
335 * @return void
336 */
337 public static function unschedule() {
338 wp_clear_scheduled_hook( self::CRON_HOOK );
339 }
340
341 /* ---------------------------------------------------------------------
342 * E-commerce > Subscription settings
343 * ------------------------------------------------------------------- */
344
345 /**
346 * Read one E-commerce > Subscription setting. Defaults resolve through
347 * DB::default_settings(), so an unsaved install behaves like the
348 * documented defaults.
349 *
350 * @param string $key Setting key without the prefix (e.g. 'renewal_process').
351 * @return string
352 */
353 public static function setting( $key ) {
354 return (string) DB::get_settings( self::SETTING_PREFIX . $key );
355 }
356
357 /**
358 * Pure: whether automatic (off-session) renewals are enabled site-wide.
359 * A manual Renewal Mode OR Automatic Stripe Charging switched off both mean
360 * every renewal is invoiced for manual payment instead of being charged.
361 * Unknown values fall back to enabled — the defaults' behavior.
362 *
363 * @param mixed $renewal_process The renewal_process setting (auto|manual).
364 * @param mixed $stripe_auto_renew The stripe_auto_renew setting (yes|no).
365 * @return bool
366 */
367 public static function auto_renewal_globally_enabled( $renewal_process, $stripe_auto_renew ) {
368 return 'manual' !== (string) $renewal_process && 'no' !== (string) $stripe_auto_renew;
369 }
370
371 /**
372 * Whether checkout should ask Stripe to keep the payment method
373 * reusable for off-session renewals. With a site-wide manual renewal
374 * policy the card would be stored without ever being charged — so it
375 * isn't stored at all.
376 *
377 * @return bool
378 */
379 public static function should_save_payment_method() {
380 return self::auto_renewal_globally_enabled( self::setting( 'renewal_process' ), self::setting( 'stripe_auto_renew' ) );
381 }
382
383 /**
384 * Whether THIS subscription renews automatically: the site-wide policy
385 * AND the customer's own auto-renewal preference (AUTO_RENEW_META,
386 * default on).
387 *
388 * @param \WC_Order $parent Parent (subscription) order.
389 * @return bool
390 */
391 public static function auto_renewal_enabled_for( $parent ) {
392 return self::should_save_payment_method()
393 && 'no' !== (string) $parent->get_meta( self::AUTO_RENEW_META );
394 }
395
396 /* ---------------------------------------------------------------------
397 * Product settings
398 * ------------------------------------------------------------------- */
399
400 /**
401 * Render the subscription fields on the product edit screen.
402 *
403 * The checkbox is always visible; every dependent field lives inside
404 * `.bp-subscription-settings-fields`, toggled by the inline script below
405 * so the group only shows while the product is a subscription. The
406 * billing schedule is ONE field — interval count + period dropdown
407 * inline ("Renew Every [3] [Month]") — not two stacked rows.
408 *
409 * @return void
410 */
411 public static function render_product_fields() {
412 global $post;
413
414 if ( ! function_exists( 'woocommerce_wp_checkbox' ) || ! $post ) {
415 return;
416 }
417
418 $enabled = get_post_meta( $post->ID, self::PRODUCT_ENABLED_META, true );
419 $interval = max( 1, (int) get_post_meta( $post->ID, self::PRODUCT_INTERVAL_META, true ) );
420 $period = self::sanitize_period( get_post_meta( $post->ID, self::PRODUCT_PERIOD_META, true ) );
421 $periods = array(
422 'day' => __( 'Day', 'better-payment' ),
423 'week' => __( 'Week', 'better-payment' ),
424 'month' => __( 'Month', 'better-payment' ),
425 'year' => __( 'Year', 'better-payment' ),
426 );
427
428 // WooCommerce (7.0+ admin styles) gives every non-checkbox
429 // .form-field label in the options panel `line-height: 40px` to
430 // vertically center a ONE-line label against its 40px input. A label
431 // that wraps at the panel's 150px label column ("Free Days Before
432 // First Charge") gets 40px per LINE — a huge gap between the two
433 // words of one label. Restore a normal line-height and re-center
434 // with padding instead, scoped to BP's own wrapper so no other
435 // plugin's (or WooCommerce's) fields are touched. The selector
436 // mirrors WooCommerce's own — same `.wc-wp-version-gte-70` guard,
437 // same `:not(:has(checkbox/radio))` carve-out — so it outranks it
438 // exactly where it applies and applies nowhere else.
439 ?>
440 <style>
441 .wc-wp-version-gte-70 .woocommerce_options_panel .bp-subscription-options .form-field:not(:has(input[type=checkbox], input[type=radio])) label {
442 line-height: 1.5;
443 padding-top: 10px;
444 }
445
446 .woocommerce_options_panel .bp-subscription-options .bp-subscription-gateway-warning {
447 padding: 0 12px 0 12px;
448 }
449
450 .woocommerce_options_panel .bp-subscription-options .bp-subscription-gateway-warning__text {
451 display: block;
452 border-left: 4px solid #b32d2e;
453 background: #fcf0f1;
454 padding: 8px 12px;
455 line-height: 1.5;
456 }
457 </style>
458 <?php
459
460 // Hidden for the product types a subscription cannot be sold on (see
461 // type_supports_subscription()). Unknown types from other plugins are
462 // left visible — the box still works there.
463 echo '<div class="options_group bp-subscription-options hide_if_variable hide_if_grouped hide_if_external">';
464
465 $gateway_state = self::gateway_state();
466
467 // A full-width notice rather than a labelled field, so it opts out of
468 // the panel's 150px label column (see the style block above).
469 if ( 'ready' !== $gateway_state ) {
470 printf(
471 '<p class="form-field bp-subscription-gateway-warning"><span class="bp-subscription-gateway-warning__text">%s</span></p>',
472 esc_html(
473 'disabled' === $gateway_state
474 ? __( 'Better Payment (Stripe) is switched off in WooCommerce → Settings → Payments. A subscription product can only be paid with it, so customers will see no payment method at checkout until it is enabled.', 'better-payment' )
475 : __( 'Better Payment has no Stripe keys yet. A subscription product can only be paid with the Better Payment (Stripe) gateway, so customers will see no payment method at checkout until Stripe is configured.', 'better-payment' )
476 )
477 );
478 }
479
480 woocommerce_wp_checkbox(
481 array(
482 'id' => self::PRODUCT_ENABLED_META,
483 'label' => __( 'Better Payment Subscription', 'better-payment' ),
484 'description' => __( 'Bill this product on a recurring schedule through the Better Payment (Stripe) gateway.', 'better-payment' ),
485 )
486 );
487
488 echo '<div class="bp-subscription-settings-fields"' . ( 'yes' === $enabled ? '' : ' style="display:none;"' ) . '>';
489
490 ?>
491 <p class="form-field bp-subscription-schedule-field">
492 <label for="<?php echo esc_attr( self::PRODUCT_INTERVAL_META ); ?>"><?php esc_html_e( 'Renew Every', 'better-payment' ); ?></label>
493 <input
494 type="number"
495 id="<?php echo esc_attr( self::PRODUCT_INTERVAL_META ); ?>"
496 name="<?php echo esc_attr( self::PRODUCT_INTERVAL_META ); ?>"
497 value="<?php echo esc_attr( (string) $interval ); ?>"
498 min="1"
499 step="1"
500 style="width: 80px; margin-right: 8px;"
501 />
502 <select
503 id="<?php echo esc_attr( self::PRODUCT_PERIOD_META ); ?>"
504 name="<?php echo esc_attr( self::PRODUCT_PERIOD_META ); ?>"
505 style="width: auto;"
506 >
507 <?php foreach ( $periods as $value => $label ) : ?>
508 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $period, $value ); ?>><?php echo esc_html( $label ); ?></option>
509 <?php endforeach; ?>
510 </select>
511 <?php echo wp_kses_post( wc_help_tip( __( 'Billing schedule — e.g. "3" + "Month" renews every 3 months.', 'better-payment' ) ) ); ?>
512 </p>
513 <?php
514
515 woocommerce_wp_text_input(
516 array(
517 'id' => self::PRODUCT_TRIAL_DAYS_META,
518 'label' => __( 'Free Days Before First Charge', 'better-payment' ),
519 'type' => 'number',
520 'value' => (string) max( 0, (int) get_post_meta( $post->ID, self::PRODUCT_TRIAL_DAYS_META, true ) ),
521 'custom_attributes' => array(
522 'min' => '0',
523 'step' => '1',
524 ),
525 'desc_tip' => true,
526 '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' ),
527 )
528 );
529
530 woocommerce_wp_checkbox(
531 array(
532 'id' => self::USER_CANCEL_META,
533 'label' => __( 'Customer Can Cancel', 'better-payment' ),
534 'description' => __( 'Shows a Cancel button on this subscription under My Account → Subscriptions, so the buyer can stop future renewals without contacting you.', 'better-payment' ),
535 )
536 );
537
538 woocommerce_wp_text_input(
539 array(
540 'id' => self::BUTTON_TEXT_META,
541 'label' => __( 'Add to Cart Button Label', 'better-payment' ),
542 'type' => 'text',
543 'value' => (string) get_post_meta( $post->ID, self::BUTTON_TEXT_META, true ),
544 'placeholder' => __( 'e.g. Subscribe Now', 'better-payment' ),
545 'desc_tip' => true,
546 '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' ),
547 )
548 );
549
550 echo '</div>';
551 echo '</div>';
552
553 ?>
554 <script>
555 jQuery( function ( $ ) {
556 var toggleBpSubscriptionFields = function () {
557 $( '.bp-subscription-settings-fields' ).toggle(
558 $( '#<?php echo esc_js( self::PRODUCT_ENABLED_META ); ?>' ).is( ':checked' )
559 );
560 };
561
562 $( '#<?php echo esc_js( self::PRODUCT_ENABLED_META ); ?>' ).on( 'change', toggleBpSubscriptionFields );
563 toggleBpSubscriptionFields();
564 } );
565 </script>
566 <?php
567 }
568
569 /**
570 * Persist the subscription fields. Runs inside WooCommerce's own
571 * nonce-verified product save.
572 *
573 * @param \WC_Product $product The product being saved.
574 * @return void
575 */
576 public static function save_product_fields( $product ) {
577 // phpcs:disable WordPress.Security.NonceVerification.Missing -- WooCommerce verifies the product-save nonce before this hook fires.
578 $enabled = isset( $_POST[ self::PRODUCT_ENABLED_META ] ) ? 'yes' : 'no';
579 $interval = isset( $_POST[ self::PRODUCT_INTERVAL_META ] ) ? max( 1, absint( wp_unslash( $_POST[ self::PRODUCT_INTERVAL_META ] ) ) ) : 1;
580 $period = isset( $_POST[ self::PRODUCT_PERIOD_META ] ) ? self::sanitize_period( sanitize_text_field( wp_unslash( $_POST[ self::PRODUCT_PERIOD_META ] ) ) ) : 'month';
581 $trial_days = isset( $_POST[ self::PRODUCT_TRIAL_DAYS_META ] ) ? absint( wp_unslash( $_POST[ self::PRODUCT_TRIAL_DAYS_META ] ) ) : 0;
582 $user_cancel = isset( $_POST[ self::USER_CANCEL_META ] ) ? 'yes' : 'no';
583 $button_text = isset( $_POST[ self::BUTTON_TEXT_META ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::BUTTON_TEXT_META ] ) ) : '';
584 // phpcs:enable WordPress.Security.NonceVerification.Missing
585
586 $product->update_meta_data( self::PRODUCT_ENABLED_META, $enabled );
587 $product->update_meta_data( self::PRODUCT_INTERVAL_META, (string) $interval );
588 $product->update_meta_data( self::PRODUCT_PERIOD_META, $period );
589 $product->update_meta_data( self::PRODUCT_TRIAL_DAYS_META, (string) $trial_days );
590 $product->update_meta_data( self::USER_CANCEL_META, $user_cancel );
591 $product->update_meta_data( self::BUTTON_TEXT_META, $button_text );
592 }
593
594 /**
595 * Append the billing schedule to a subscription product's price HTML.
596 *
597 * @param string $price Price HTML.
598 * @param \WC_Product $product Product.
599 * @return string
600 */
601 public static function price_html_suffix( $price, $product ) {
602 if ( ! self::product_is_subscription( $product ) || '' === (string) $price ) {
603 return $price;
604 }
605
606 $interval = max( 1, (int) $product->get_meta( self::PRODUCT_INTERVAL_META ) );
607 $period = self::sanitize_period( $product->get_meta( self::PRODUCT_PERIOD_META ) );
608 $trial_days = max( 0, (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ) );
609
610 $label = sprintf(
611 /* translators: 1: price HTML, 2: billing schedule (e.g. "month" or "every 3 months") */
612 __( '%1$s / %2$s', 'better-payment' ),
613 $price,
614 self::describe_schedule( $interval, $period )
615 );
616
617 if ( $trial_days > 0 ) {
618 $label .= ' ' . sprintf(
619 /* translators: %d: number of free trial days before the first payment */
620 _n( 'with a %d-day free trial', 'with a %d-day free trial', $trial_days, 'better-payment' ),
621 $trial_days
622 );
623 }
624
625 return $label;
626 }
627
628 /**
629 * Replace the Add to Cart label with the product's custom button text.
630 * Applies only to subscription products with a non-blank custom text;
631 * every other product keeps WooCommerce's own label.
632 *
633 * @param string $text The default button text.
634 * @param mixed $product The product.
635 * @return string
636 */
637 public static function filter_add_to_cart_text( $text, $product ) {
638 if ( ! self::product_is_subscription( $product ) ) {
639 return $text;
640 }
641
642 $custom = trim( (string) $product->get_meta( self::BUTTON_TEXT_META ) );
643
644 return '' !== $custom ? $custom : $text;
645 }
646
647 /* ---------------------------------------------------------------------
648 * Detection + pure helpers
649 * ------------------------------------------------------------------- */
650
651 /**
652 * Product types this module cannot sell as a subscription. Pure.
653 *
654 * A variable product's meta lives on the PARENT, while the cart, the
655 * order line and the renewal all carry the VARIATION — and a variation
656 * does not inherit its parent's custom meta. So a variable product with
657 * the box ticked advertised "/ month" on the shop and in the cart, was
658 * locked to this gateway at checkout, and then activated no
659 * subscription at all: the customer was billed once for something sold
660 * as recurring. Grouped and external products have no line of their own
661 * to bill either. Rejecting them here keeps every surface telling the
662 * same story; a product type from another plugin is left alone (it may
663 * well work), so nothing that worked before stops working.
664 *
665 * @param mixed $type Product type slug.
666 * @return bool
667 */
668 public static function type_supports_subscription( $type ) {
669 /**
670 * Filters the product types a Better Payment subscription cannot use.
671 *
672 * @since 2.4.0
673 *
674 * @param string[] $unsupported Unsupported product type slugs.
675 */
676 $unsupported = apply_filters(
677 'better_payment/woocommerce/unsupported_subscription_product_types',
678 array( 'variable', 'grouped', 'external' )
679 );
680
681 return ! in_array( (string) $type, (array) $unsupported, true );
682 }
683
684 /**
685 * Whether a product is a Better Payment subscription product.
686 *
687 * @param mixed $product Product (or anything else — safely rejected).
688 * @return bool
689 */
690 public static function product_is_subscription( $product ) {
691 if ( ! $product instanceof \WC_Product || 'yes' !== $product->get_meta( self::PRODUCT_ENABLED_META ) ) {
692 return false;
693 }
694
695 return self::type_supports_subscription( $product->get_type() );
696 }
697
698 /**
699 * The order's line items whose product is a subscription product.
700 *
701 * @param mixed $order Order (or anything else — safely rejected).
702 * @return \WC_Order_Item_Product[]
703 */
704 public static function order_subscription_items( $order ) {
705 $items = array();
706
707 if ( ! $order instanceof \WC_Order ) {
708 return $items;
709 }
710
711 foreach ( $order->get_items() as $item ) {
712 if ( ! $item instanceof \WC_Order_Item_Product ) {
713 continue;
714 }
715
716 if ( self::product_is_subscription( $item->get_product() ) ) {
717 $items[] = $item;
718 }
719 }
720
721 return $items;
722 }
723
724 /**
725 * Whether the order contains at least one subscription product.
726 *
727 * @param \WC_Order $order Order.
728 * @return bool
729 */
730 public static function order_contains_subscription( $order ) {
731 return count( self::order_subscription_items( $order ) ) > 0;
732 }
733
734 /**
735 * Whether the current cart contains at least one subscription product.
736 *
737 * @return bool
738 */
739 public static function cart_contains_subscription() {
740 if ( ! function_exists( 'WC' ) || null === WC()->cart ) {
741 return false;
742 }
743
744 foreach ( WC()->cart->get_cart() as $cart_item ) {
745 if ( isset( $cart_item['data'] ) && self::product_is_subscription( $cart_item['data'] ) ) {
746 return true;
747 }
748 }
749
750 return false;
751 }
752
753 /* ---------------------------------------------------------------------
754 * Cart composition (one subscription per order)
755 * ------------------------------------------------------------------- */
756
757 /**
758 * Whether the one-subscription-per-order rule is enforced. Read live on
759 * every check so a site can opt out per request.
760 *
761 * @return bool
762 */
763 public static function single_subscription_cart_enforced() {
764 /**
765 * Filters whether a cart may hold only one subscription and nothing
766 * else. Turning this off does NOT make the engine multi-subscription
767 * — see cart_composition_error() for what actually breaks.
768 *
769 * @since 2.4.0
770 *
771 * @param bool $enforced Default true.
772 */
773 return (bool) apply_filters( 'better_payment/woocommerce/single_subscription_cart', true );
774 }
775
776 /**
777 * Pure: the storefront error a cart composition produces, or '' when the
778 * cart may be checked out.
779 *
780 * A Better Payment subscription is bought on its own because every
781 * subscription fact lives on ONE parent order: activate_subscription()
782 * snapshots the schedule from a single item, one Stripe payment method is
783 * stored per order, and create_renewal_order() re-adds that order's
784 * subscription products on every cycle. So a second subscription in the
785 * same order would be billed on the first one's schedule, and a one-off
786 * product would be silently re-charged at every renewal.
787 *
788 * Identity is the line's PRODUCT id, not the line: two lines of the same
789 * subscription product (two variations, or a re-add) are one subscription
790 * bought more than once — which the cart quantity field already allows —
791 * so only a second, DIFFERENT subscription product is refused.
792 *
793 * @param mixed $lines Cart lines as array( 'product_id' => int, 'is_subscription' => bool ).
794 * @return string Error message, or '' when the composition is allowed.
795 */
796 public static function cart_composition_error( $lines ) {
797 $subscriptions = array();
798 $others = 0;
799
800 foreach ( (array) $lines as $line ) {
801 if ( ! is_array( $line ) ) {
802 continue;
803 }
804
805 if ( empty( $line['is_subscription'] ) ) {
806 ++$others;
807 continue;
808 }
809
810 $product_id = isset( $line['product_id'] ) ? (int) $line['product_id'] : 0;
811 $subscriptions[ $product_id ] = true;
812 }
813
814 if ( count( $subscriptions ) > 1 ) {
815 return __( 'Only one subscription can be purchased at a time. Please remove the other subscription from your cart and buy it separately.', 'better-payment' );
816 }
817
818 if ( count( $subscriptions ) > 0 && $others > 0 ) {
819 return __( 'A subscription has to be purchased on its own. Please remove the other products from your cart and buy them separately.', 'better-payment' );
820 }
821
822 return '';
823 }
824
825 /**
826 * The current cart described for cart_composition_error(). Empty (and so
827 * always valid) without WooCommerce.
828 *
829 * The key is `product_id` — the PARENT id for a variation — because that
830 * is what `woocommerce_add_to_cart_validation` reports for the product
831 * being added, and the two must be comparable.
832 *
833 * @return array
834 */
835 public static function cart_composition_lines() {
836 $lines = array();
837
838 if ( ! function_exists( 'WC' ) || null === WC()->cart ) {
839 return $lines;
840 }
841
842 foreach ( WC()->cart->get_cart() as $cart_item ) {
843 $lines[] = array(
844 'product_id' => isset( $cart_item['product_id'] ) ? (int) $cart_item['product_id'] : 0,
845 'is_subscription' => self::product_is_subscription( isset( $cart_item['data'] ) ? $cart_item['data'] : null ),
846 );
847 }
848
849 return $lines;
850 }
851
852 /**
853 * Refuse an add to cart that would break the one-subscription-per-order
854 * rule (`woocommerce_add_to_cart_validation`).
855 *
856 * The Store API applies this same filter and converts the notice into its
857 * error response, so the blocks cart is covered without a second hook.
858 *
859 * @param bool $passed Validation result so far.
860 * @param int $product_id Product being added (parent id for a variation).
861 * @return bool
862 */
863 public static function validate_add_to_cart_composition( $passed, $product_id ) {
864 if ( ! $passed || ! function_exists( 'wc_get_product' ) || ! self::single_subscription_cart_enforced() ) {
865 return $passed;
866 }
867
868 $lines = self::cart_composition_lines();
869 $lines[] = array(
870 'product_id' => (int) $product_id,
871 'is_subscription' => self::product_is_subscription( wc_get_product( $product_id ) ),
872 );
873
874 $error = self::cart_composition_error( $lines );
875
876 if ( '' === $error ) {
877 return $passed;
878 }
879
880 wc_add_notice( $error, 'error' );
881
882 return false;
883 }
884
885 /**
886 * Re-check the whole cart's composition (`woocommerce_check_cart_items`,
887 * which runs on the cart AND checkout pages, classic and blocks).
888 *
889 * Catches what add-to-cart validation cannot: a cart filled before the
890 * product became a subscription, an order-again, or any add that skipped
891 * validation. The notice blocks checkout until a line is removed — the
892 * cart is never emptied for the customer.
893 *
894 * @return void
895 */
896 public static function enforce_cart_composition() {
897 if ( ! self::single_subscription_cart_enforced() ) {
898 return;
899 }
900
901 $error = self::cart_composition_error( self::cart_composition_lines() );
902
903 if ( '' !== $error ) {
904 wc_add_notice( $error, 'error' );
905 }
906 }
907
908 /* ---------------------------------------------------------------------
909 * Free trial ($0 at checkout, first payment at trial end)
910 * ------------------------------------------------------------------- */
911
912 /**
913 * Whether the current customer may use a product's free trial. One trial
914 * per customer per product: anyone who has (or ever had) a Better
915 * Payment subscription containing the product pays the regular price —
916 * otherwise cancelling and re-subscribing would chain free trials
917 * forever. Guests cannot be checked and are given the benefit of the
918 * doubt.
919 *
920 * @param mixed $product The product (non-products are ineligible).
921 * @return bool
922 */
923 public static function trial_eligible( $product ) {
924 $product_id = $product instanceof \WC_Product ? (int) $product->get_id() : 0;
925 $user_id = (int) get_current_user_id();
926
927 $eligible = $product_id > 0 && ! self::customer_has_subscription_for_product( $user_id, $product_id );
928
929 // A guest has no account to check, so the trial used to be given to
930 // every guest checkout — including the same person buying the same
931 // product for the fifth time. The billing e-mail is the only identity
932 // a guest has, and by the time WooCommerce recalculates the totals
933 // for the checkout form it knows it, so use it. Still "benefit of the
934 // doubt" while the e-mail is unknown (the shop page, the cart page):
935 // nothing is charged there, and the checkout recalculation decides.
936 if ( $eligible && $user_id < 1 ) {
937 $eligible = ! self::customer_has_subscription_for_email( self::current_billing_email(), $product_id );
938 }
939
940 /**
941 * Filters whether the current customer is eligible for a
942 * subscription product's free trial.
943 *
944 * @since 2.4.0
945 *
946 * @param bool $eligible Eligibility resolved so far.
947 * @param int $product_id Product id.
948 * @param int $user_id Current user id (0 = guest).
949 */
950 return (bool) apply_filters( 'better_payment/woocommerce/trial_eligible', $eligible, $product_id, $user_id );
951 }
952
953 /**
954 * The billing e-mail WooCommerce currently holds for the shopper, or ''
955 * when it has none (a logged-out visitor who has not reached the
956 * checkout form yet).
957 *
958 * @return string
959 */
960 public static function current_billing_email() {
961 if ( ! function_exists( 'WC' ) || null === WC()->customer ) {
962 return '';
963 }
964
965 $email = (string) WC()->customer->get_billing_email();
966
967 return is_email( $email ) ? $email : '';
968 }
969
970 /**
971 * Whether a billing e-mail has already been used for a Better Payment
972 * subscription containing the product — the guest counterpart of
973 * customer_has_subscription_for_product(). Any status counts: a
974 * cancelled trial still used the trial up.
975 *
976 * @param mixed $email Billing e-mail ('' and non-strings never match).
977 * @param mixed $product_id Product (or variation) id.
978 * @return bool
979 */
980 public static function customer_has_subscription_for_email( $email, $product_id ) {
981 static $cache = array();
982
983 $email = is_string( $email ) ? trim( $email ) : '';
984 $product_id = (int) $product_id;
985
986 if ( '' === $email || $product_id < 1 || ! function_exists( 'wc_get_orders' ) ) {
987 return false;
988 }
989
990 $key = strtolower( $email ) . ':' . $product_id;
991
992 if ( isset( $cache[ $key ] ) ) {
993 return $cache[ $key ];
994 }
995
996 $orders = wc_get_orders(
997 array(
998 'billing_email' => $email,
999 'limit' => 100,
1000 'type' => 'shop_order',
1001 'return' => 'objects',
1002 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 100), per-request cached, trial products only.
1003 array(
1004 'key' => self::STATUS_META,
1005 'compare' => 'EXISTS',
1006 ),
1007 ),
1008 )
1009 );
1010
1011 $cache[ $key ] = self::orders_contain_product( $orders, $product_id );
1012
1013 return $cache[ $key ];
1014 }
1015
1016 /**
1017 * Whether any of the orders carries the product as a subscription line.
1018 *
1019 * @param mixed $orders Orders to scan.
1020 * @param int $product_id Product (or variation) id.
1021 * @return bool
1022 */
1023 private static function orders_contain_product( $orders, $product_id ) {
1024 if ( ! is_array( $orders ) ) {
1025 return false;
1026 }
1027
1028 foreach ( $orders as $order ) {
1029 foreach ( self::order_subscription_items( $order ) as $item ) {
1030 if ( (int) $item->get_product_id() === $product_id || (int) $item->get_variation_id() === $product_id ) {
1031 return true;
1032 }
1033 }
1034 }
1035
1036 return false;
1037 }
1038
1039 /**
1040 * Whether a customer has (or had) a Better Payment subscription order
1041 * containing the product. Scans the customer's subscription parent
1042 * orders (STATUS_META present); any status counts — a cancelled trial
1043 * still used up the trial. Cached per request; the scan is bounded and
1044 * runs only for logged-in customers on trial products.
1045 *
1046 * @param int $user_id Customer user id (0 = guest, never matches).
1047 * @param int $product_id Product (or variation) id.
1048 * @return bool
1049 */
1050 public static function customer_has_subscription_for_product( $user_id, $product_id ) {
1051 static $cache = array();
1052
1053 $user_id = (int) $user_id;
1054 $product_id = (int) $product_id;
1055
1056 if ( $user_id < 1 || $product_id < 1 || ! function_exists( 'wc_get_orders' ) ) {
1057 return false;
1058 }
1059
1060 $key = $user_id . ':' . $product_id;
1061
1062 if ( isset( $cache[ $key ] ) ) {
1063 return $cache[ $key ];
1064 }
1065
1066 $orders = wc_get_orders(
1067 array(
1068 'customer_id' => $user_id,
1069 'limit' => 100,
1070 'type' => 'shop_order',
1071 'return' => 'objects',
1072 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 100), per-request cached, trial products only.
1073 array(
1074 'key' => self::STATUS_META,
1075 'compare' => 'EXISTS',
1076 ),
1077 ),
1078 )
1079 );
1080
1081 $cache[ $key ] = self::orders_contain_product( $orders, $product_id );
1082
1083 return $cache[ $key ];
1084 }
1085
1086 /**
1087 * One page of a customer's subscription parent orders, newest first —
1088 * the My Account > Subscriptions list query. Ownership is enforced here
1089 * (customer_id), so a caller can never page through someone else's
1090 * subscriptions. Renewal orders never match: the subscription status
1091 * meta only exists on parent orders.
1092 *
1093 * @param int $user_id Customer user id.
1094 * @param int $page 1-based page number.
1095 * @param int $per_page Orders per page.
1096 * @return array { @type \WC_Order[] $orders, @type int $total, @type int $max_pages }
1097 * @since 2.4.0
1098 */
1099 public static function customer_subscriptions( $user_id, $page = 1, $per_page = 10 ) {
1100 $empty = array(
1101 'orders' => array(),
1102 'total' => 0,
1103 'max_pages' => 0,
1104 );
1105
1106 if ( ! function_exists( 'wc_get_orders' ) || (int) $user_id <= 0 ) {
1107 return $empty;
1108 }
1109
1110 $result = wc_get_orders(
1111 array(
1112 'customer_id' => (int) $user_id,
1113 'type' => 'shop_order',
1114 'limit' => max( 1, (int) $per_page ),
1115 'paged' => max( 1, (int) $page ),
1116 'paginate' => true,
1117 'orderby' => 'date',
1118 'order' => 'DESC',
1119 'return' => 'objects',
1120 '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.
1121 array(
1122 'key' => self::STATUS_META,
1123 'compare' => 'EXISTS',
1124 ),
1125 ),
1126 )
1127 );
1128
1129 if ( ! is_object( $result ) || ! isset( $result->orders ) ) {
1130 return $empty;
1131 }
1132
1133 return array(
1134 'orders' => is_array( $result->orders ) ? $result->orders : array(),
1135 'total' => isset( $result->total ) ? (int) $result->total : 0,
1136 'max_pages' => isset( $result->max_num_pages ) ? (int) $result->max_num_pages : 0,
1137 );
1138 }
1139
1140 /**
1141 * The subscription's pending renewal invoice, when one is awaiting
1142 * payment (a past_due recovery or a manual-policy renewal). Null when
1143 * there is none, it is gone, or it no longer needs payment.
1144 *
1145 * @param mixed $parent Parent (subscription) order.
1146 * @return \WC_Order|null
1147 * @since 2.4.0
1148 */
1149 public static function pending_renewal_order( $parent ) {
1150 if ( ! $parent instanceof \WC_Order || ! function_exists( 'wc_get_order' ) ) {
1151 return null;
1152 }
1153
1154 $pending_id = (int) $parent->get_meta( self::PENDING_RENEWAL_META );
1155
1156 if ( $pending_id <= 0 ) {
1157 return null;
1158 }
1159
1160 $pending = wc_get_order( $pending_id );
1161
1162 if ( $pending instanceof \WC_Order && $pending->needs_payment() ) {
1163 return $pending;
1164 }
1165
1166 return null;
1167 }
1168
1169 /**
1170 * Start scoping trial pricing to the totals calculation: while this
1171 * filter is attached, get_price() answers 0 for trial-eligible
1172 * subscription products, so an eligible customer's line totals compute
1173 * to $0 — the first payment is charged when the trial ends (see
1174 * activate_subscription()). Attached on `woocommerce_before_calculate_totals`
1175 * and detached on `woocommerce_calculate_totals` /
1176 * `woocommerce_after_calculate_totals`, so display price readers (the
1177 * Store API's cart item prices, price HTML, the blocks cart's
1178 * sale/"Save" detection) always see the product's real price. The
1179 * product object is never mutated — mutating it (the pre-2.4.0 draft's
1180 * set_price(0)) made the blocks cart render the trial as a fake
1181 * 100%-off sale. Renewal orders (built server-side from the product's
1182 * real price) are never affected.
1183 *
1184 * @return void
1185 */
1186 public static function add_trial_price_filter() {
1187 add_filter( 'woocommerce_product_get_price', array( __CLASS__, 'zero_trial_price' ), 100, 2 );
1188 }
1189
1190 /**
1191 * Stop scoping trial pricing — the counterpart of
1192 * add_trial_price_filter(), run as soon as totals calculation ends.
1193 *
1194 * @return void
1195 */
1196 public static function remove_trial_price_filter() {
1197 remove_filter( 'woocommerce_product_get_price', array( __CLASS__, 'zero_trial_price' ), 100 );
1198 }
1199
1200 /**
1201 * Filter callback for `woocommerce_product_get_price` while totals are
1202 * being calculated: 0 for a trial-eligible subscription product with a
1203 * trial configured, the real price for everything else.
1204 *
1205 * @param mixed $price The product price.
1206 * @param mixed $product The product.
1207 * @return mixed
1208 */
1209 public static function zero_trial_price( $price, $product ) {
1210 if ( ! self::product_is_subscription( $product ) ) {
1211 return $price;
1212 }
1213
1214 return self::trial_zeroed_price(
1215 $price,
1216 (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ),
1217 self::trial_eligible( $product )
1218 );
1219 }
1220
1221 /**
1222 * Pure: the price a subscription product's line calculates at, given
1223 * its trial configuration and the customer's eligibility. Only a
1224 * configured trial (>= 1 day) AND an eligible customer zero the price.
1225 *
1226 * @param mixed $price The product's real price.
1227 * @param mixed $trial_days Free trial duration in days.
1228 * @param bool $eligible Whether the customer may use the trial.
1229 * @return mixed 0 when the trial applies, the untouched price otherwise.
1230 */
1231 public static function trial_zeroed_price( $price, $trial_days, $eligible ) {
1232 if ( (int) $trial_days < 1 || ! $eligible ) {
1233 return $price;
1234 }
1235
1236 return 0;
1237 }
1238
1239 /**
1240 * Filter callback for `woocommerce_cart_needs_payment`: a subscription
1241 * cart always goes through payment, even at $0 — the gateway must run to
1242 * collect the card (setup-mode session) or to activate the subscription
1243 * (manual renewal policy). WooCommerce would otherwise complete a free
1244 * order without ever calling a gateway.
1245 *
1246 * @param mixed $needs WooCommerce's own answer.
1247 * @return bool
1248 */
1249 public static function filter_cart_needs_payment( $needs ) {
1250 return (bool) $needs || self::cart_contains_subscription();
1251 }
1252
1253 /**
1254 * Filter callback for `woocommerce_order_needs_payment` — the order-side
1255 * twin of filter_cart_needs_payment(), so the receipt page, the
1256 * return-URL verification and on_payment_confirmed() treat a pending $0
1257 * trial order as payable instead of skipping it.
1258 *
1259 * @param mixed $needs WooCommerce's own answer.
1260 * @param mixed $order The order.
1261 * @return bool
1262 */
1263 public static function filter_order_needs_payment( $needs, $order ) {
1264 if ( $needs || ! $order instanceof \WC_Order ) {
1265 return (bool) $needs;
1266 }
1267
1268 return self::needs_payment_override(
1269 (float) $order->get_total(),
1270 $order->has_status( array( 'pending', 'failed' ) ),
1271 self::order_contains_subscription( $order )
1272 );
1273 }
1274
1275 /**
1276 * The id of the ONLY gateway a subscription purchase may use.
1277 *
1278 * Resolves through Gateway::GATEWAY_ID when the gateway class is loadable.
1279 * Gateway extends WC_Payment_Gateway, so without WooCommerce the class
1280 * cannot load at all (the test environment runs WC-free) — the literal
1281 * fallback is pinned to the constant by SubscriptionsPureTest so the two
1282 * can never drift.
1283 *
1284 * @return string
1285 */
1286 public static function gateway_id() {
1287 if ( class_exists( 'WC_Payment_Gateway' ) ) {
1288 return Gateway::GATEWAY_ID;
1289 }
1290
1291 return 'better_payment_stripe';
1292 }
1293
1294 /**
1295 * Pure: what state the Better Payment gateway is in for a subscription
1296 * sale — 'ready', 'disabled' (the WooCommerce gateway is switched off)
1297 * or 'unconfigured' (switched on, but Better Payment has no Stripe keys).
1298 *
1299 * Both failing states mean the same thing at checkout: the subscription
1300 * restriction removes every other gateway and leaves nothing behind, so
1301 * the product cannot be bought at all. That is the safe outcome (no
1302 * other gateway can store the card renewals are charged with) but it was
1303 * also a silent one — WooCommerce just says no payment methods are
1304 * available. This is what the product screen and the checkout notice
1305 * explain instead.
1306 *
1307 * @param mixed $settings The gateway's WooCommerce settings option.
1308 * @param bool $stripe_configured Whether Better Payment has Stripe keys.
1309 * @return string 'ready' | 'disabled' | 'unconfigured'
1310 */
1311 public static function gateway_ready_state( $settings, $stripe_configured ) {
1312 $enabled = is_array( $settings ) && isset( $settings['enabled'] ) ? (string) $settings['enabled'] : 'no';
1313
1314 if ( 'yes' !== $enabled ) {
1315 return 'disabled';
1316 }
1317
1318 return $stripe_configured ? 'ready' : 'unconfigured';
1319 }
1320
1321 /**
1322 * The live gateway state (see gateway_ready_state()).
1323 *
1324 * @return string 'ready' | 'disabled' | 'unconfigured'
1325 */
1326 public static function gateway_state() {
1327 return self::gateway_ready_state(
1328 get_option( 'woocommerce_' . self::gateway_id() . '_settings' ),
1329 StripeService::is_configured()
1330 );
1331 }
1332
1333 /**
1334 * Pure: the checkout message for a subscription purchase that has no
1335 * payment method left, or the untouched default for every other cart.
1336 *
1337 * @param string $message WooCommerce's own message.
1338 * @param bool $restricted Whether this purchase requires the BP gateway.
1339 * @param string $state Gateway state (see gateway_ready_state()).
1340 * @return string
1341 */
1342 public static function no_payment_methods_text( $message, $restricted, $state ) {
1343 if ( ! $restricted || 'ready' === $state ) {
1344 return $message;
1345 }
1346
1347 return __( 'This subscription can only be paid through Better Payment (Stripe), and that payment method is not available yet. Please contact the store owner.', 'better-payment' );
1348 }
1349
1350 /**
1351 * Filter callback for `woocommerce_no_available_payment_methods_message`.
1352 *
1353 * @param string $message WooCommerce's own message.
1354 * @return string
1355 */
1356 public static function no_payment_methods_message( $message ) {
1357 return self::no_payment_methods_text( (string) $message, self::checkout_requires_bp_gateway(), self::gateway_state() );
1358 }
1359
1360 /**
1361 * Pure: reduce an available-gateways list to the Better Payment gateway
1362 * alone when the purchase requires it.
1363 *
1364 * When the Better Payment gateway is not itself in the list (disabled or
1365 * unconfigured), the result is deliberately EMPTY — WooCommerce then shows
1366 * its "no payment methods available" notice. Letting another gateway
1367 * through instead would sell a subscription that can never renew.
1368 *
1369 * @param mixed $gateways Available gateways (id => gateway).
1370 * @param bool $restrict Whether the purchase must use the BP gateway.
1371 * @return mixed
1372 */
1373 public static function filter_gateways_for_subscription( $gateways, $restrict ) {
1374 if ( ! $restrict || ! is_array( $gateways ) ) {
1375 return $gateways;
1376 }
1377
1378 return array_intersect_key( $gateways, array( self::gateway_id() => true ) );
1379 }
1380
1381 /**
1382 * Whether the purchase being paid for right now must use the BP gateway:
1383 * a subscription product in the cart, or — on the order-pay page — an
1384 * order that is a renewal invoice or contains a subscription product.
1385 *
1386 * @return bool
1387 */
1388 public static function checkout_requires_bp_gateway() {
1389 // Order-pay endpoint: manually paying an invoiced renewal (the
1390 // past_due recovery path) or a pending subscription order. The cart
1391 // is irrelevant here — the order alone decides.
1392 if ( function_exists( 'is_wc_endpoint_url' ) && is_wc_endpoint_url( 'order-pay' ) ) {
1393 $order = function_exists( 'wc_get_order' ) ? wc_get_order( absint( get_query_var( 'order-pay' ) ) ) : false;
1394
1395 if ( ! $order instanceof \WC_Order ) {
1396 return false;
1397 }
1398
1399 return (int) $order->get_meta( self::RENEWAL_PARENT_META ) > 0 || self::order_contains_subscription( $order );
1400 }
1401
1402 return self::cart_contains_subscription();
1403 }
1404
1405 /**
1406 * Filter callback for `woocommerce_available_payment_gateways`.
1407 *
1408 * @param mixed $gateways Available gateways (id => gateway).
1409 * @return mixed
1410 */
1411 public static function restrict_available_gateways( $gateways ) {
1412 // The WooCommerce settings screens run this filter too — the shop
1413 // owner must always see every gateway there.
1414 if ( is_admin() && ! wp_doing_ajax() ) {
1415 return $gateways;
1416 }
1417
1418 return self::filter_gateways_for_subscription( $gateways, self::checkout_requires_bp_gateway() );
1419 }
1420
1421 /**
1422 * Pure: add the off-session reuse instruction to a Checkout Session
1423 * request built by OrderHandler::build_session_request(). Stripe then
1424 * attaches the payment method to the session's customer for later
1425 * off-session charges.
1426 *
1427 * @param array $request Checkout Session request.
1428 * @return array
1429 */
1430 public static function add_off_session_setup( $request ) {
1431 if ( ! isset( $request['payment_intent_data'] ) || ! is_array( $request['payment_intent_data'] ) ) {
1432 $request['payment_intent_data'] = array();
1433 }
1434
1435 $request['payment_intent_data']['setup_future_usage'] = 'off_session';
1436
1437 return $request;
1438 }
1439
1440 /**
1441 * Pure: clamp a billing period to the supported set.
1442 *
1443 * @param mixed $period Raw value.
1444 * @return string
1445 */
1446 public static function sanitize_period( $period ) {
1447 return in_array( $period, self::PERIODS, true ) ? $period : 'month';
1448 }
1449
1450 /**
1451 * Pure: the next renewal timestamp for a schedule.
1452 *
1453 * @param int $interval Billing interval count.
1454 * @param string $period Billing period.
1455 * @param int $from Base timestamp (0 = now).
1456 * @return int
1457 */
1458 public static function next_payment_timestamp( $interval, $period, $from = 0 ) {
1459 $interval = max( 1, (int) $interval );
1460 $period = self::sanitize_period( $period );
1461 $from = (int) $from > 0 ? (int) $from : time();
1462
1463 return (int) strtotime( '+' . $interval . ' ' . $period, $from );
1464 }
1465
1466 /**
1467 * Pure: when a free trial ends — the timestamp of the FIRST payment on a
1468 * trial checkout. Nothing is paid at checkout, so the paid schedule
1469 * starts at trial end (NOT first cycle + trial).
1470 *
1471 * @param int $trial_days Free trial duration in days.
1472 * @param int $from Base timestamp (0 = now).
1473 * @return int
1474 */
1475 public static function trial_end_timestamp( $trial_days, $from = 0 ) {
1476 $from = (int) $from > 0 ? (int) $from : time();
1477
1478 return $from + ( max( 0, (int) $trial_days ) * DAY_IN_SECONDS );
1479 }
1480
1481 /**
1482 * Pure: whether a zero-total order must still go through payment so the
1483 * gateway can collect the card. Only pending/failed subscription orders
1484 * at $0 qualify — everything else keeps WooCommerce's own answer.
1485 *
1486 * @param float $total Order total.
1487 * @param bool $payable_status Order is in a payable status (pending/failed).
1488 * @param bool $contains_subscription Order contains a subscription product.
1489 * @return bool
1490 */
1491 public static function needs_payment_override( $total, $payable_status, $contains_subscription ) {
1492 return $contains_subscription && $payable_status && (float) $total < 0.01;
1493 }
1494
1495 /**
1496 * The agreed per-cycle line prices for a subscription's items, taken at
1497 * activation.
1498 *
1499 * The **undiscounted** line subtotal is what is stored, deliberately:
1500 *
1501 * - Renewals used to be built from the product's CURRENT price, so
1502 * editing the product silently re-priced every running subscription —
1503 * the one thing the schedule snapshot beside it exists to prevent.
1504 * - A checkout coupon is not carried forward. It discounted the first
1505 * payment; perpetuating it would hand out a permanent price cut the
1506 * shop owner never agreed to, and there is no UI here to say
1507 * otherwise. (Same default WooCommerce applies to a non-recurring
1508 * coupon.)
1509 * - A trial line checked out at $0. The trial is free, the subscription
1510 * is not, so the product's price is snapshotted instead of the zero.
1511 *
1512 * @param mixed $items Subscription order items.
1513 * @param bool $trial_applied Whether the trial zeroed the lines at checkout.
1514 * @return array[] Each entry: `product_id`, `quantity`, `subtotal`, `total`.
1515 */
1516 public static function renewal_line_snapshot( $items, $trial_applied ) {
1517 $lines = array();
1518
1519 if ( ! is_array( $items ) ) {
1520 return $lines;
1521 }
1522
1523 foreach ( $items as $item ) {
1524 if ( ! $item instanceof \WC_Order_Item_Product ) {
1525 continue;
1526 }
1527
1528 $product = $item->get_product();
1529 $product_id = $product ? (int) $product->get_id() : (int) $item->get_product_id();
1530 $quantity = max( 1, (int) $item->get_quantity() );
1531
1532 if ( $product_id < 1 ) {
1533 continue;
1534 }
1535
1536 $subtotal = ( $trial_applied && $product )
1537 ? (float) $product->get_price() * $quantity
1538 : (float) $item->get_subtotal();
1539
1540 $lines[] = array(
1541 'product_id' => $product_id,
1542 'quantity' => $quantity,
1543 'subtotal' => $subtotal,
1544 'total' => $subtotal,
1545 );
1546 }
1547
1548 return $lines;
1549 }
1550
1551 /**
1552 * Pure: the snapshotted amounts for one renewal line, or null when the
1553 * subscription has no snapshot for it — every subscription that was
1554 * already running when this shipped, which keeps renewing at the
1555 * product's current price exactly as it always did.
1556 *
1557 * @param mixed $snapshot Stored snapshot.
1558 * @param int $product_id Product (or variation) id of the line.
1559 * @param int $occurrence Which line of that product (0-based), for a
1560 * subscription holding the same product twice.
1561 * @return array|null `subtotal` and `total`.
1562 */
1563 public static function renewal_line_amounts( $snapshot, $product_id, $occurrence = 0 ) {
1564 if ( ! is_array( $snapshot ) ) {
1565 return null;
1566 }
1567
1568 $product_id = (int) $product_id;
1569 $seen = 0;
1570
1571 foreach ( $snapshot as $line ) {
1572 if ( ! is_array( $line ) || ! isset( $line['product_id'] ) || (int) $line['product_id'] !== $product_id ) {
1573 continue;
1574 }
1575
1576 if ( $seen === (int) $occurrence ) {
1577 return array(
1578 'subtotal' => isset( $line['subtotal'] ) ? (float) $line['subtotal'] : 0.0,
1579 'total' => isset( $line['total'] ) ? (float) $line['total'] : 0.0,
1580 );
1581 }
1582
1583 $seen++;
1584 }
1585
1586 return null;
1587 }
1588
1589 /**
1590 * Pure: whether a renewal total must be charged through Stripe at all.
1591 *
1592 * A renewal can legitimately come due at $0 — create_renewal_order()
1593 * re-adds the parent's products at their CURRENT price, so a 100% sale
1594 * price (or a free plan) produces a zero-total renewal order. Stripe
1595 * rejects a PaymentIntent of amount 0 outright, so attempting the charge
1596 * turns a perfectly healthy subscription into past_due and emails the
1597 * customer an invoice for $0. The counterpart of the $0 branch
1598 * Gateway::process_payment() already has for the FIRST payment.
1599 *
1600 * @param float $total Renewal order total.
1601 * @return bool
1602 */
1603 public static function renewal_requires_charge( $total ) {
1604 return (float) $total >= 0.01;
1605 }
1606
1607 /**
1608 * Pure: build the Stripe setup-mode Checkout Session request for a $0
1609 * (free-trial) subscription checkout. A setup session collects and saves
1610 * a card without charging — the counterpart of
1611 * OrderHandler::build_session_request() for orders with nothing to pay.
1612 * No line_items / payment_intent_data (invalid in setup mode); the
1613 * SetupIntent carries the same metadata back-references the engine's
1614 * payment protocol uses.
1615 *
1616 * @param array $data {
1617 * @type string $bp_order_id Better Payment order id (stripe_xxx).
1618 * @type int $wc_order_id WooCommerce order id.
1619 * @type string $success_url Return URL on success.
1620 * @type string $cancel_url Return URL on cancel.
1621 * @type string $customer Stripe customer id to attach the card to.
1622 * }
1623 * @return array
1624 */
1625 public static function build_setup_session_request( $data ) {
1626 $metadata = array(
1627 'order_id' => (string) $data['bp_order_id'],
1628 'wc_order_id' => (string) $data['wc_order_id'],
1629 );
1630
1631 $request = array(
1632 'mode' => 'setup',
1633 'success_url' => (string) $data['success_url'],
1634 'cancel_url' => (string) $data['cancel_url'],
1635 'locale' => 'auto',
1636 'payment_method_types' => array( 'card' ),
1637 'client_reference_id' => (string) $data['wc_order_id'],
1638 'metadata' => $metadata,
1639 'setup_intent_data' => array(
1640 'metadata' => $metadata,
1641 ),
1642 );
1643
1644 // Without a customer the saved payment method would attach to
1645 // nothing and be unusable for off-session renewals.
1646 if ( ! empty( $data['customer'] ) ) {
1647 $request['customer'] = (string) $data['customer'];
1648 }
1649
1650 return $request;
1651 }
1652
1653 /**
1654 * Pure: human description of a schedule ("month", "every 3 months").
1655 *
1656 * @param int $interval Billing interval count.
1657 * @param string $period Billing period.
1658 * @return string
1659 */
1660 public static function describe_schedule( $interval, $period ) {
1661 $interval = max( 1, (int) $interval );
1662 $period = self::sanitize_period( $period );
1663
1664 $singular = array(
1665 'day' => __( 'day', 'better-payment' ),
1666 'week' => __( 'week', 'better-payment' ),
1667 'month' => __( 'month', 'better-payment' ),
1668 'year' => __( 'year', 'better-payment' ),
1669 );
1670 $plural = array(
1671 'day' => __( 'days', 'better-payment' ),
1672 'week' => __( 'weeks', 'better-payment' ),
1673 'month' => __( 'months', 'better-payment' ),
1674 'year' => __( 'years', 'better-payment' ),
1675 );
1676
1677 if ( 1 === $interval ) {
1678 return $singular[ $period ];
1679 }
1680
1681 return sprintf(
1682 /* translators: 1: interval count, 2: period plural (e.g. "months") */
1683 __( 'every %1$d %2$s', 'better-payment' ),
1684 $interval,
1685 $plural[ $period ]
1686 );
1687 }
1688
1689 /**
1690 * Pure: customer-facing label for a subscription status. Unknown values
1691 * fall back to a readable form of the raw status (underscores to
1692 * spaces) rather than an empty badge — a status whose key does not
1693 * match the label map (on_hold vs on-hold) renders exactly that.
1694 *
1695 * @param string $status Raw `_bp_subscription_status` meta value.
1696 * @return string
1697 * @since 2.4.0
1698 */
1699 public static function customer_status_label( $status ) {
1700 $status = strtolower( (string) $status );
1701
1702 $map = array(
1703 'active' => __( 'Active', 'better-payment' ),
1704 'past_due' => __( 'Payment past due', 'better-payment' ),
1705 'cancelled' => __( 'Cancelled', 'better-payment' ),
1706 'completed' => __( 'Completed', 'better-payment' ),
1707 );
1708
1709 if ( isset( $map[ $status ] ) ) {
1710 return $map[ $status ];
1711 }
1712
1713 return ucfirst( str_replace( '_', ' ', $status ) );
1714 }
1715
1716 /**
1717 * Pure: build the off-session PaymentIntent request for a renewal order.
1718 * Amount conversion mirrors OrderHandler::build_session_request() (minor
1719 * units), and metadata carries the same back-references the engine's
1720 * session protocol uses.
1721 *
1722 * @param array $data {
1723 * @type string $bp_order_id Better Payment order id (stripe_xxx).
1724 * @type int $wc_order_id Renewal WooCommerce order id.
1725 * @type int $parent_order_id Parent (subscription) order id.
1726 * @type string $order_number Renewal order display number.
1727 * @type float $amount Renewal total (major units).
1728 * @type string $currency ISO currency code.
1729 * @type string $customer Stripe customer id.
1730 * @type string $payment_method Stripe payment method id.
1731 * @type string $site_name Blog name for the description.
1732 * }
1733 * @return array
1734 */
1735 public static function build_renewal_intent_request( $data ) {
1736 $order_number = ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) ( isset( $data['wc_order_id'] ) ? $data['wc_order_id'] : '' );
1737 $site_name = ! empty( $data['site_name'] ) ? (string) $data['site_name'] : '';
1738
1739 /* translators: 1: order number, 2: site name */
1740 $description = trim( sprintf( __( 'Subscription renewal %1$s — %2$s', 'better-payment' ), '#' . $order_number, $site_name ), " \t—" );
1741
1742 return array(
1743 'amount' => (int) round( (float) $data['amount'] * 100 ),
1744 'currency' => strtolower( (string) $data['currency'] ),
1745 'customer' => (string) $data['customer'],
1746 'payment_method' => (string) $data['payment_method'],
1747 'off_session' => 'true',
1748 'confirm' => 'true',
1749 'description' => $description,
1750 'metadata' => array(
1751 'order_id' => (string) $data['bp_order_id'],
1752 'wc_order_id' => (string) $data['wc_order_id'],
1753 'bp_subscription_parent' => (string) $data['parent_order_id'],
1754 ),
1755 );
1756 }
1757
1758 /**
1759 * Pure: build the Better Payment transaction row for a charged renewal.
1760 * Mirrors OrderHandler::build_transaction_data() so renewals appear in
1761 * the Better Payment transaction UI exactly like first payments (and
1762 * OrderHandler::extract_wc_order_id() resolves them the same way).
1763 *
1764 * @param array $data Same shape as build_renewal_intent_request().
1765 * @param object $intent Decoded Stripe PaymentIntent.
1766 * @return array
1767 */
1768 public static function build_renewal_transaction_data( $data, $intent ) {
1769 $form_fields_info = array(
1770 'wc_order_id' => (int) $data['wc_order_id'],
1771 'wc_order_number' => ! empty( $data['order_number'] ) ? (string) $data['order_number'] : (string) $data['wc_order_id'],
1772 'source' => 'stripe',
1773 'amount' => (float) $data['amount'],
1774 'primary_email' => ! empty( $data['customer_email'] ) ? sanitize_email( $data['customer_email'] ) : '',
1775 'primary_name' => ! empty( $data['customer_name'] ) ? sanitize_text_field( $data['customer_name'] ) : '',
1776 'bp_subscription_parent' => (int) $data['parent_order_id'],
1777 );
1778
1779 // Stripe reports a settled PaymentIntent as 'succeeded' — a status
1780 // the transaction taxonomy (Classes\Helper v2 maps, DB counters, the
1781 // admin list's tag) does not know, and unknown statuses classify as
1782 // "Incomplete". Store 'paid' — the exact status the engine's
1783 // verified first payment writes — so renewals count as completed
1784 // everywhere. A non-settled status passes through verbatim (renew()
1785 // only records settled charges today; the passthrough keeps a future
1786 // caller honest rather than laundering failures into 'paid').
1787 $status = ! empty( $intent->status ) ? sanitize_text_field( $intent->status ) : 'succeeded';
1788 if ( 'succeeded' === $status ) {
1789 $status = 'paid';
1790 }
1791
1792 return array(
1793 'amount' => (float) $data['amount'],
1794 'order_id' => (string) $data['bp_order_id'],
1795 // UTC, like every other writer of this table (see Classes\PaymentDateMigration).
1796 'payment_date' => gmdate( 'Y-m-d H:i:s' ),
1797 'source' => 'stripe',
1798 'transaction_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '',
1799 'customer_info' => maybe_serialize( $intent ),
1800 'form_fields_info' => maybe_serialize( $form_fields_info ),
1801 'obj_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '',
1802 'status' => $status,
1803 'currency' => (string) $data['currency'],
1804 'referer' => 'woocommerce',
1805 'campaign_id' => '',
1806 );
1807 }
1808
1809 /* ---------------------------------------------------------------------
1810 * Activation + settlement
1811 * ------------------------------------------------------------------- */
1812
1813 /**
1814 * An order reached a paid status without the gateway return telling us.
1815 *
1816 * The completion contract used to be reachable only from the Stripe
1817 * return (and from the cron charge), so a subscription order an admin
1818 * marked complete by hand — or an invoiced renewal settled offline and
1819 * ticked off in wp-admin — activated nothing and advanced nothing, while
1820 * the cron went on charging the card for a period the customer had
1821 * already paid. Both handlers on the contract are idempotent (the
1822 * subscription's status meta; the renewal's settled meta), so an order
1823 * that DID come through the return path changes nothing here.
1824 *
1825 * @param mixed $order_id The order that changed status.
1826 * @return void
1827 */
1828 public static function maybe_complete_from_status( $order_id ) {
1829 if ( ! function_exists( 'wc_get_order' ) ) {
1830 return;
1831 }
1832
1833 $order = wc_get_order( $order_id );
1834
1835 if ( ! $order instanceof \WC_Order || $order->needs_payment() ) {
1836 return;
1837 }
1838
1839 $is_renewal = (int) $order->get_meta( self::RENEWAL_PARENT_META ) > 0;
1840
1841 // Already activated: nothing to do, and worth answering from meta
1842 // before touching the line items — this hook fires for every order on
1843 // the site, including one an admin re-completes.
1844 if ( ! $is_renewal && '' !== (string) $order->get_meta( self::STATUS_META ) ) {
1845 return;
1846 }
1847
1848 if ( ! $is_renewal && ! self::order_contains_subscription( $order ) ) {
1849 return; // Ordinary purchase — the overwhelming majority.
1850 }
1851
1852 /**
1853 * Fires for a Better Payment subscription order that became paid
1854 * outside the gateway return. Same contract, same consumers.
1855 *
1856 * @since 2.4.0
1857 *
1858 * @param \WC_Order $order The paid order.
1859 * @param object|null $row The order's Better Payment transaction row, when it has one.
1860 */
1861 do_action( 'better_payment/woocommerce/payment_complete', $order, self::transaction_row( $order ) );
1862 }
1863
1864 /**
1865 * The Better Payment transaction row an order was paid with, or null.
1866 *
1867 * The status bridge above must pass it, and this is not a nicety: the
1868 * engine's own consumer marks the order paid with `payment_complete()`
1869 * and fires the module's hook *after* that, so the status transition —
1870 * and therefore the bridge — runs FIRST. Passing null there would
1871 * activate the subscription with no row, `harvest_payment_method()`
1872 * would find no Checkout Session to read, and every subscription bought
1873 * through the normal checkout would end up with no reusable payment
1874 * method: correct-looking, and silently invoiced by hand forever after.
1875 * The row is already written and confirmed by then, so reading it back is
1876 * the whole fix. An order with no row (a $0 trial under a manual renewal
1877 * policy, an offline payment) legitimately returns null.
1878 *
1879 * @param mixed $order The order.
1880 * @return object|null
1881 */
1882 public static function transaction_row( $order ) {
1883 if ( ! $order instanceof \WC_Order ) {
1884 return null;
1885 }
1886
1887 $row_id = (int) $order->get_meta( '_bp_transaction_id' );
1888
1889 if ( $row_id < 1 ) {
1890 return null;
1891 }
1892
1893 $row = DB::get_transaction( $row_id );
1894
1895 return empty( $row->id ) ? null : $row;
1896 }
1897
1898 /**
1899 * Consume the module's payment-complete hook. A first payment on a
1900 * subscription cart activates the subscription; a paid renewal order
1901 * (cron-charged or manually paid) advances the parent's schedule.
1902 *
1903 * @param mixed $order The paid order.
1904 * @param object|null $row The Better Payment transaction row.
1905 * @return void
1906 */
1907 public static function on_order_paid( $order, $row ) {
1908 if ( ! $order instanceof \WC_Order ) {
1909 return;
1910 }
1911
1912 $parent_id = (int) $order->get_meta( self::RENEWAL_PARENT_META );
1913
1914 if ( $parent_id > 0 ) {
1915 self::settle_renewal( $order, $parent_id );
1916 return;
1917 }
1918
1919 if ( '' !== (string) $order->get_meta( self::STATUS_META ) ) {
1920 return; // Already activated (duplicate confirmation).
1921 }
1922
1923 if ( ! self::order_contains_subscription( $order ) ) {
1924 return; // Ordinary one-off purchase — the common case.
1925 }
1926
1927 self::activate_subscription( $order, $row );
1928 }
1929
1930 /**
1931 * Activate a subscription on its parent order: snapshot the schedule,
1932 * save the reusable Stripe customer + payment method, set the first
1933 * renewal date.
1934 *
1935 * @param \WC_Order $order Parent order.
1936 * @param object|null $row Better Payment transaction row.
1937 * @return void
1938 */
1939 public static function activate_subscription( $order, $row ) {
1940 $items = self::order_subscription_items( $order );
1941
1942 if ( empty( $items ) ) {
1943 return;
1944 }
1945
1946 // The schedule (and the trial / cancellation policies) come from the
1947 // first subscription item, snapshotted at activation so a later
1948 // product edit never rewrites a customer's agreed terms (one
1949 // schedule per order — documented limitation).
1950 $product = $items[0]->get_product();
1951 $interval = $product ? max( 1, (int) $product->get_meta( self::PRODUCT_INTERVAL_META ) ) : 1;
1952 $period = self::sanitize_period( $product ? $product->get_meta( self::PRODUCT_PERIOD_META ) : 'month' );
1953 $trial_days = $product ? max( 0, (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ) ) : 0;
1954 $user_cancel = ( $product && 'yes' === $product->get_meta( self::USER_CANCEL_META ) ) ? 'yes' : 'no';
1955
1956 // The trial was applied iff the customer actually checked out at $0
1957 // for this item (zero_trial_price() zeroed the line during cart
1958 // totals calculation — the zeroed line IS the marker). A trial
1959 // product bought by an ineligible returning subscriber has a paid
1960 // line and renews on the plain schedule.
1961 $trial_applied = $trial_days > 0 && (float) $items[0]->get_total() < 0.01;
1962
1963 // Trial → the first payment is due when the trial ends (nothing was
1964 // paid at checkout). No trial → the paid first cycle just started,
1965 // next payment one cycle out.
1966 $next = $trial_applied
1967 ? self::trial_end_timestamp( $trial_days )
1968 : self::next_payment_timestamp( $interval, $period );
1969
1970 $harvested = self::harvest_payment_method( $order, $row );
1971
1972 $order->update_meta_data( self::STATUS_META, 'active' );
1973 $order->update_meta_data( self::INTERVAL_META, (string) $interval );
1974 $order->update_meta_data( self::PERIOD_META, $period );
1975 $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
1976 $order->update_meta_data( self::USER_CANCEL_META, $user_cancel );
1977 $order->update_meta_data( self::RENEWAL_COUNT_META, '0' );
1978 // What the customer agreed to pay per cycle. A trial line checked out
1979 // at $0, so the snapshot takes the product's price in that case —
1980 // the trial is free, the subscription is not.
1981 $order->update_meta_data( self::PRICE_SNAPSHOT_META, self::renewal_line_snapshot( $items, $trial_applied ) );
1982
1983 if ( $trial_applied ) {
1984 $order->update_meta_data( self::TRIAL_APPLIED_META, 'yes' );
1985 }
1986
1987 $trial_note = '';
1988 if ( $trial_applied ) {
1989 $trial_note = ' ' . sprintf(
1990 /* translators: %d: number of free trial days */
1991 _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' ),
1992 $trial_days
1993 );
1994 } elseif ( $trial_days > 0 ) {
1995 $trial_note = ' ' . __( 'The product offers a free trial but this customer already used one for it, so the regular schedule applies.', 'better-payment' );
1996 }
1997
1998 $order->add_order_note(
1999 sprintf(
2000 /* translators: 1: billing schedule, 2: next renewal date, 3: note about the saved payment method, 4: trial note (may be empty) */
2001 __( 'Better Payment: subscription activated — renews every %1$s. Next renewal: %2$s. %3$s%4$s', 'better-payment' ),
2002 self::describe_schedule( $interval, $period ),
2003 date_i18n( get_option( 'date_format' ), $next ),
2004 $harvested
2005 ? __( 'A reusable payment method was saved for automatic renewals.', 'better-payment' )
2006 : __( 'No reusable payment method could be saved — renewals will need manual payment.', 'better-payment' ),
2007 $trial_note
2008 )
2009 );
2010 $order->save();
2011
2012 // Relation table: the order that started the subscription.
2013 self::record_order_relation( $order->get_id(), $order, SubscriptionRelationModel::TYPE_NEW );
2014
2015 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' ) . ').' );
2016
2017 /**
2018 * Fires after a Better Payment subscription is activated on an order.
2019 *
2020 * @since 2.4.0
2021 *
2022 * @param \WC_Order $order The parent (subscription) order.
2023 */
2024 do_action( 'better_payment/woocommerce/subscription_activated', $order );
2025 }
2026
2027 /**
2028 * Record a row in the e-commerce subscription <-> order relation table
2029 * (source 'woo'). `$subscription_id` is the parent (subscription) order
2030 * id; `$order` is the related order — the parent itself for TYPE_NEW, a
2031 * renewal order for TYPE_RENEW. The model dedupes, so re-recording the
2032 * same relation is harmless.
2033 *
2034 * @param int $subscription_id Parent (subscription) order id.
2035 * @param mixed $order The related order (non-orders are rejected).
2036 * @param string $type SubscriptionRelationModel::TYPE_NEW|TYPE_RENEW.
2037 * @return void
2038 */
2039 public static function record_order_relation( $subscription_id, $order, $type ) {
2040 if ( ! $order instanceof \WC_Order ) {
2041 return;
2042 }
2043
2044 $items = self::order_subscription_items( $order );
2045 $order_item_id = ! empty( $items ) ? (int) $items[0]->get_id() : 0;
2046
2047 SubscriptionRelationModel::record(
2048 array(
2049 'subscription_id' => (int) $subscription_id,
2050 'order_id' => $order->get_id(),
2051 'order_item_id' => $order_item_id,
2052 'type' => $type,
2053 'source' => SubscriptionRelationModel::SOURCE_WOO,
2054 )
2055 );
2056 }
2057
2058 /**
2059 * Save the reusable Stripe customer + payment method from the verified
2060 * Checkout Session onto the order.
2061 *
2062 * @param \WC_Order $order Parent order.
2063 * @param object|null $row Better Payment transaction row (obj_id = session id).
2064 * @return bool Whether both identifiers are now stored.
2065 */
2066 public static function harvest_payment_method( $order, $row ) {
2067 if ( '' !== (string) $order->get_meta( self::CUSTOMER_META ) && '' !== (string) $order->get_meta( self::PAYMENT_METHOD_META ) ) {
2068 return true;
2069 }
2070
2071 $session_id = isset( $row->obj_id ) ? (string) $row->obj_id : '';
2072
2073 if ( '' === $session_id || 0 !== strpos( $session_id, 'cs_' ) ) {
2074 return false;
2075 }
2076
2077 $keys = StripeService::get_global_keys();
2078
2079 if ( empty( $keys['secret_key'] ) ) {
2080 return false;
2081 }
2082
2083 // Expand both intent kinds: a paid checkout saved the card on its
2084 // PaymentIntent; a $0 free-trial checkout saved it on the setup-mode
2085 // session's SetupIntent.
2086 $session = StripeService::retrieve_checkout_session( $session_id, $keys['secret_key'], array( 'payment_intent', 'setup_intent' ) );
2087
2088 if ( is_wp_error( $session ) ) {
2089 OrderHandler::log( 'Could not retrieve session ' . $session_id . ' to save the reusable payment method for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
2090 return false;
2091 }
2092
2093 $customer = '';
2094 if ( ! empty( $session->customer ) ) {
2095 $customer = is_object( $session->customer ) && ! empty( $session->customer->id ) ? (string) $session->customer->id : (string) $session->customer;
2096 }
2097
2098 $payment_method = '';
2099 if ( ! empty( $session->payment_intent ) && is_object( $session->payment_intent ) && ! empty( $session->payment_intent->payment_method ) ) {
2100 $pm = $session->payment_intent->payment_method;
2101 $payment_method = is_object( $pm ) && ! empty( $pm->id ) ? (string) $pm->id : (string) $pm;
2102 }
2103
2104 if ( '' === $payment_method && ! empty( $session->setup_intent ) && is_object( $session->setup_intent ) && ! empty( $session->setup_intent->payment_method ) ) {
2105 $pm = $session->setup_intent->payment_method;
2106 $payment_method = is_object( $pm ) && ! empty( $pm->id ) ? (string) $pm->id : (string) $pm;
2107 }
2108
2109 if ( '' === $customer || '' === $payment_method ) {
2110 return false;
2111 }
2112
2113 $order->update_meta_data( self::CUSTOMER_META, sanitize_text_field( $customer ) );
2114 $order->update_meta_data( self::PAYMENT_METHOD_META, sanitize_text_field( $payment_method ) );
2115
2116 return true;
2117 }
2118
2119 /**
2120 * A renewal order was paid — advance the parent's schedule and (re)set
2121 * it to active. This is also the past_due recovery path: manually paying
2122 * the invoiced renewal order lands here through the same hook.
2123 *
2124 * @param mixed $renewal_order The paid renewal order (anything else is rejected).
2125 * @param mixed $parent_id Parent (subscription) order id.
2126 * @return void
2127 */
2128 public static function settle_renewal( $renewal_order, $parent_id ) {
2129 if ( ! $renewal_order instanceof \WC_Order ) {
2130 return;
2131 }
2132
2133 // Settling advances the schedule a whole cycle, so it happens once
2134 // per renewal order. The same payment can arrive twice — the gateway
2135 // return and the order-status bridge both lead here — and the second
2136 // pass would give the customer a free billing period.
2137 if ( 'yes' === (string) $renewal_order->get_meta( self::RENEWAL_SETTLED_META ) ) {
2138 return;
2139 }
2140
2141 $renewal_order->update_meta_data( self::RENEWAL_SETTLED_META, 'yes' );
2142 $renewal_order->save();
2143
2144 $parent = function_exists( 'wc_get_order' ) ? wc_get_order( $parent_id ) : false;
2145
2146 if ( ! $parent ) {
2147 OrderHandler::log( 'Renewal order #' . $renewal_order->get_id() . ' paid but parent order #' . $parent_id . ' was not found.', 'error' );
2148 return;
2149 }
2150
2151 if ( 'cancelled' === (string) $parent->get_meta( self::STATUS_META ) ) {
2152 $parent->add_order_note(
2153 sprintf(
2154 /* translators: %d: renewal order id */
2155 __( 'Better Payment: renewal order #%d was paid but the subscription is cancelled — the schedule was not advanced.', 'better-payment' ),
2156 $renewal_order->get_id()
2157 )
2158 );
2159 $parent->save();
2160 return;
2161 }
2162
2163 $renewal_count = (int) $parent->get_meta( self::RENEWAL_COUNT_META ) + 1;
2164
2165 $parent->update_meta_data( self::RENEWAL_COUNT_META, (string) $renewal_count );
2166 $parent->update_meta_data( self::LAST_PAYMENT_META, (string) time() );
2167 // A paid renewal clears the outstanding manual-renewal invoice guard
2168 // (no-op for auto-charged renewals).
2169 $parent->delete_meta_data( self::PENDING_RENEWAL_META );
2170
2171 $interval = max( 1, (int) $parent->get_meta( self::INTERVAL_META ) );
2172 $period = self::sanitize_period( $parent->get_meta( self::PERIOD_META ) );
2173 $next = self::next_payment_timestamp( $interval, $period );
2174
2175 $parent->update_meta_data( self::STATUS_META, 'active' );
2176 $parent->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
2177 $parent->add_order_note(
2178 sprintf(
2179 /* translators: 1: renewal order id, 2: next renewal date */
2180 __( 'Better Payment: renewal order #%1$d paid. Next renewal: %2$s.', 'better-payment' ),
2181 $renewal_order->get_id(),
2182 date_i18n( get_option( 'date_format' ), $next )
2183 )
2184 );
2185 $parent->save();
2186
2187 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.' );
2188
2189 /**
2190 * Fires after a subscription renewal payment settles.
2191 *
2192 * @since 2.4.0
2193 *
2194 * @param \WC_Order $parent The parent (subscription) order.
2195 * @param \WC_Order $renewal_order The paid renewal order.
2196 */
2197 do_action( 'better_payment/woocommerce/subscription_renewed', $parent, $renewal_order );
2198 }
2199
2200 /* ---------------------------------------------------------------------
2201 * Renewal processing (cron)
2202 * ------------------------------------------------------------------- */
2203
2204 /**
2205 * Charge every due subscription. Runs hourly via WP-Cron.
2206 *
2207 * @return void
2208 */
2209 public static function process_due_subscriptions() {
2210 if ( ! function_exists( 'wc_get_orders' ) || ! StripeService::is_configured() ) {
2211 return;
2212 }
2213
2214 /**
2215 * Filters how many due subscriptions one cron run charges. Each one
2216 * is a Stripe round-trip, so the batch is bounded; a shop with more
2217 * due subscriptions than this catches up over the following runs.
2218 *
2219 * @since 2.4.0
2220 *
2221 * @param int $limit Subscriptions per run.
2222 */
2223 $limit = (int) apply_filters( 'better_payment/woocommerce/renewal_batch_size', 20 );
2224
2225 /**
2226 * Filters the parent-order statuses a due subscription is renewed
2227 * from. A subscription order parked in any other status (on-hold,
2228 * refunded, a custom status) is skipped in silence, which is why
2229 * this is filterable rather than fixed.
2230 *
2231 * @since 2.4.0
2232 *
2233 * @param string[] $statuses Order statuses.
2234 */
2235 $statuses = (array) apply_filters( 'better_payment/woocommerce/renewal_parent_statuses', array( 'wc-processing', 'wc-completed' ) );
2236
2237 $orders = wc_get_orders(
2238 array(
2239 'limit' => max( 1, $limit ),
2240 'type' => 'shop_order',
2241 'status' => $statuses,
2242 'return' => 'objects',
2243 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 20), hourly cron.
2244 array(
2245 'key' => self::STATUS_META,
2246 'value' => 'active',
2247 ),
2248 array(
2249 'key' => self::NEXT_PAYMENT_META,
2250 'value' => time(),
2251 'compare' => '<=',
2252 'type' => 'NUMERIC',
2253 ),
2254 ),
2255 )
2256 );
2257
2258 if ( empty( $orders ) || ! is_array( $orders ) ) {
2259 return;
2260 }
2261
2262 foreach ( $orders as $order ) {
2263 self::renew( $order );
2264 }
2265 }
2266
2267 /**
2268 * Create and charge one renewal for a due subscription.
2269 *
2270 * On success the renewal order is marked paid, the payment is recorded in
2271 * the Better Payment transactions table, and the parent's schedule
2272 * advances (through the shared payment-complete hook). On failure the
2273 * subscription goes past_due: auto-charging stops and the customer is
2274 * emailed the pending renewal order to pay manually.
2275 *
2276 * @param \WC_Order $parent Parent (subscription) order.
2277 * @return void
2278 */
2279 public static function renew( $parent ) {
2280 // Idempotency guard — a slow Stripe call must not let an overlapping
2281 // cron run double-charge the same subscription.
2282 $lock_key = 'bp_wc_sub_renew_' . $parent->get_id();
2283
2284 if ( get_transient( $lock_key ) ) {
2285 return;
2286 }
2287
2288 set_transient( $lock_key, 1, 10 * MINUTE_IN_SECONDS );
2289
2290 // Manual renewal policy — the site-wide Renewal Mode / Automatic
2291 // Stripe Charging settings or the customer's own auto-renew opt-out:
2292 // invoice the renewal instead of charging it.
2293 if ( ! self::auto_renewal_enabled_for( $parent ) ) {
2294 self::request_manual_renewal( $parent );
2295 return;
2296 }
2297
2298 $customer = (string) $parent->get_meta( self::CUSTOMER_META );
2299 $payment_method = (string) $parent->get_meta( self::PAYMENT_METHOD_META );
2300
2301 if ( '' === $customer || '' === $payment_method ) {
2302 // No stored card to charge — invoice the renewal for manual
2303 // payment instead of stalling the subscription in past_due with
2304 // nothing for the customer to pay. (This is also how a
2305 // subscription sold while renewals were manual keeps renewing
2306 // after the site switches back to automatic.)
2307 self::request_manual_renewal( $parent );
2308 return;
2309 }
2310
2311 // An invoice may already be outstanding — the renewal was invoiced
2312 // while renewals were manual (site policy, the customer's own
2313 // opt-out, or no stored card) and automatic charging has since come
2314 // back. Charge THAT order instead of creating a second one: the
2315 // customer holds a pay link for it, and two live orders for one
2316 // billing period is how a subscription gets paid twice.
2317 $renewal = self::pending_renewal_order( $parent );
2318
2319 if ( $renewal instanceof \WC_Order ) {
2320 $renewal->add_order_note( __( 'Better Payment: automatic renewal resumed — this invoiced renewal is being charged instead of a new order being created.', 'better-payment' ) );
2321 $renewal->save();
2322 } else {
2323 $renewal = self::create_renewal_order( $parent );
2324 }
2325
2326 if ( ! $renewal instanceof \WC_Order ) {
2327 OrderHandler::log( 'Could not create a renewal order for subscription order #' . $parent->get_id() . '.', 'error' );
2328 return;
2329 }
2330
2331 // A $0 renewal — the product is on a 100% sale, or priced free.
2332 // create_renewal_order() re-adds the parent's products at their
2333 // CURRENT price, so this is reachable on any subscription whose price
2334 // has since been discounted to nothing. Stripe rejects a
2335 // PaymentIntent of amount 0, so charging it would fail, call
2336 // mark_past_due() and invoice the customer $0 — turning a healthy
2337 // subscription into a broken one over a discount the site owner
2338 // chose. Complete it directly instead, exactly as
2339 // Gateway::process_payment() completes a $0 FIRST payment, and let
2340 // the shared completion contract advance the schedule.
2341 if ( ! self::renewal_requires_charge( (float) $renewal->get_total() ) ) {
2342 $renewal->add_order_note( __( 'Better Payment: subscription renewal total is zero — nothing to charge. The renewal was completed without contacting Stripe.', 'better-payment' ) );
2343 $renewal->payment_complete();
2344 $renewal->save();
2345
2346 OrderHandler::log( 'Renewal order #' . $renewal->get_id() . ' for subscription order #' . $parent->get_id() . ' completed without a charge (zero total).' );
2347
2348 // No Better Payment transaction row is written: nothing was paid
2349 // and there is no Stripe object to record. Same precedent as the
2350 // $0 first payment under a manual renewal policy
2351 // (Gateway::process_payment()).
2352 do_action( 'better_payment/woocommerce/payment_complete', $renewal, null );
2353 return;
2354 }
2355
2356 $keys = StripeService::get_global_keys();
2357 $bp_order_id = \Better_Payment\Lite\Classes\Handler::new_order_id( 'stripe' );
2358
2359 $data = array(
2360 'bp_order_id' => $bp_order_id,
2361 'wc_order_id' => $renewal->get_id(),
2362 'parent_order_id' => $parent->get_id(),
2363 'order_number' => $renewal->get_order_number(),
2364 'amount' => (float) $renewal->get_total(),
2365 'currency' => $renewal->get_currency(),
2366 'customer' => $customer,
2367 'payment_method' => $payment_method,
2368 'customer_email' => $renewal->get_billing_email(),
2369 'customer_name' => trim( $renewal->get_billing_first_name() . ' ' . $renewal->get_billing_last_name() ),
2370 'site_name' => get_bloginfo( 'name' ),
2371 );
2372
2373 $intent = StripeService::create_payment_intent( self::build_renewal_intent_request( $data ), $keys['secret_key'] );
2374
2375 if ( is_wp_error( $intent ) || empty( $intent->status ) || 'succeeded' !== $intent->status ) {
2376 $reason = is_wp_error( $intent )
2377 ? $intent->get_error_message()
2378 : sprintf(
2379 /* translators: %s: Stripe PaymentIntent status */
2380 __( 'the charge did not succeed (status: %s)', 'better-payment' ),
2381 ! empty( $intent->status ) ? sanitize_text_field( $intent->status ) : 'unknown'
2382 );
2383
2384 // A mis-configured Stripe account is not a failed payment. When
2385 // the keys are swapped (test ↔ live, or a new account), every
2386 // stored customer and payment method belongs to the other
2387 // account, so EVERY subscription would fail at once — each one
2388 // set past_due and each customer emailed an invoice for a card
2389 // that was working yesterday. Hold the subscription instead: no
2390 // status change, no customer email, an order note and a log line
2391 // for the shop owner, and the next cron run retries.
2392 if ( is_wp_error( $intent ) && self::renewal_failure_is_configuration( $intent->get_error_data() ) ) {
2393 self::hold_for_configuration( $parent, $renewal, $reason );
2394 return;
2395 }
2396
2397 self::mark_past_due( $parent, $renewal, $reason );
2398
2399 /**
2400 * Fires after an automatic subscription renewal charge fails.
2401 *
2402 * @since 2.4.0
2403 *
2404 * @param \WC_Order $parent The parent (subscription) order.
2405 * @param \WC_Order $renewal The pending renewal order.
2406 * @param string $reason Failure reason.
2407 */
2408 do_action( 'better_payment/woocommerce/subscription_renewal_failed', $parent, $renewal, $reason );
2409 return;
2410 }
2411
2412 // Record the payment through the engine's own writer, then mark the
2413 // renewal order paid.
2414 $transaction_row_id = Handler::payment_create( self::build_renewal_transaction_data( $data, $intent ) );
2415
2416 $renewal->update_meta_data( '_bp_transaction_id', (string) $transaction_row_id );
2417 $renewal->update_meta_data( '_bp_payment_id', $bp_order_id );
2418 $renewal->update_meta_data( '_bp_gateway', 'stripe' );
2419 $renewal->update_meta_data( '_bp_payment_status', sanitize_text_field( $intent->status ) );
2420 $renewal->update_meta_data( '_bp_paid_at', current_time( 'mysql' ) );
2421 $renewal->add_order_note(
2422 sprintf(
2423 /* translators: 1: Stripe PaymentIntent id, 2: Better Payment record id */
2424 __( 'Better Payment: subscription renewal charged off-session. Transaction ID: %1$s (Better Payment record #%2$d).', 'better-payment' ),
2425 sanitize_text_field( $intent->id ),
2426 (int) $transaction_row_id
2427 )
2428 );
2429 $renewal->payment_complete( sanitize_text_field( $intent->id ) );
2430 $renewal->save();
2431
2432 OrderHandler::log( 'Renewal order #' . $renewal->get_id() . ' charged for subscription order #' . $parent->get_id() . ' (' . $bp_order_id . ').' );
2433
2434 $row = $transaction_row_id ? DB::get_transaction( (int) $transaction_row_id ) : null;
2435
2436 // Same completion contract as a verified first payment — this is what
2437 // advances the parent's schedule (see on_order_paid()).
2438 do_action( 'better_payment/woocommerce/payment_complete', $renewal, $row );
2439 }
2440
2441 /**
2442 * Create the pending renewal order for a subscription: the parent's
2443 * subscription line items at their CURRENT product price, billed to the
2444 * parent's billing address through this gateway.
2445 *
2446 * @param \WC_Order $parent Parent (subscription) order.
2447 * @return \WC_Order|null
2448 */
2449 public static function create_renewal_order( $parent ) {
2450 if ( ! function_exists( 'wc_create_order' ) ) {
2451 return null;
2452 }
2453
2454 $items = self::order_subscription_items( $parent );
2455
2456 if ( empty( $items ) ) {
2457 return null;
2458 }
2459
2460 $renewal = wc_create_order(
2461 array(
2462 'customer_id' => $parent->get_customer_id(),
2463 'status' => 'wc-pending',
2464 'created_via' => 'better_payment_subscription',
2465 )
2466 );
2467
2468 if ( is_wp_error( $renewal ) ) {
2469 return null;
2470 }
2471
2472 $added = 0;
2473 $snapshot = $parent->get_meta( self::PRICE_SNAPSHOT_META );
2474 $seen = array();
2475
2476 foreach ( $items as $item ) {
2477 $product = $item->get_product();
2478
2479 if ( ! $product ) {
2480 continue;
2481 }
2482
2483 $product_id = (int) $product->get_id();
2484 $occurrence = isset( $seen[ $product_id ] ) ? $seen[ $product_id ] : 0;
2485
2486 $seen[ $product_id ] = $occurrence + 1;
2487
2488 // The agreed price, when the subscription has one. Without a
2489 // snapshot — a subscription already running when this shipped —
2490 // add_product() prices the line from the product, which is the
2491 // behavior those subscriptions have always had.
2492 $amounts = self::renewal_line_amounts( $snapshot, $product_id, $occurrence );
2493
2494 $renewal->add_product(
2495 $product,
2496 $item->get_quantity(),
2497 null === $amounts ? array() : array(
2498 'subtotal' => $amounts['subtotal'],
2499 'total' => $amounts['total'],
2500 )
2501 );
2502 $added++;
2503 }
2504
2505 if ( 0 === $added ) {
2506 $renewal->delete( true );
2507 return null;
2508 }
2509
2510 $renewal->set_address( $parent->get_address( 'billing' ), 'billing' );
2511 $renewal->set_payment_method( Gateway::GATEWAY_ID );
2512 $renewal->set_payment_method_title( $parent->get_payment_method_title() );
2513 $renewal->update_meta_data( self::RENEWAL_PARENT_META, (string) $parent->get_id() );
2514 $renewal->update_meta_data( self::CUSTOMER_META, (string) $parent->get_meta( self::CUSTOMER_META ) );
2515 $renewal->update_meta_data( self::PAYMENT_METHOD_META, (string) $parent->get_meta( self::PAYMENT_METHOD_META ) );
2516 $renewal->add_order_note(
2517 sprintf(
2518 /* translators: %d: parent order id */
2519 __( 'Better Payment: subscription renewal order for order #%d.', 'better-payment' ),
2520 $parent->get_id()
2521 )
2522 );
2523 $renewal->calculate_totals();
2524 $renewal->save();
2525
2526 // Relation table: renewal order -> its parent subscription.
2527 self::record_order_relation( $parent->get_id(), $renewal, SubscriptionRelationModel::TYPE_RENEW );
2528
2529 /**
2530 * Fires after a subscription renewal order is created (before it is
2531 * charged).
2532 *
2533 * @since 2.4.0
2534 *
2535 * @param \WC_Order $renewal The pending renewal order.
2536 * @param \WC_Order $parent The parent (subscription) order.
2537 */
2538 do_action( 'better_payment/woocommerce/subscription_renewal_order_created', $renewal, $parent );
2539
2540 return $renewal;
2541 }
2542
2543 /**
2544 * Pure: whether a failed renewal charge is the SITE's fault rather than
2545 * the customer's — the Stripe keys were changed, the account no longer
2546 * holds this customer, the request was rejected outright.
2547 *
2548 * Stripe's own taxonomy does the sorting: `card_error` is the customer
2549 * (declined, expired, insufficient funds), and everything else is the
2550 * integration — `authentication_error` (bad key), `invalid_request_error`
2551 * with `resource_missing` (the customer/payment method belongs to
2552 * another account), `api_error`, `rate_limit_error`.
2553 *
2554 * @param mixed $data WP_Error data from StripeService (`type`, `code`, …).
2555 * @return bool
2556 */
2557 public static function renewal_failure_is_configuration( $data ) {
2558 if ( ! is_array( $data ) ) {
2559 return false;
2560 }
2561
2562 $type = isset( $data['type'] ) ? (string) $data['type'] : '';
2563 $code = isset( $data['code'] ) ? (string) $data['code'] : '';
2564
2565 if ( 'card_error' === $type ) {
2566 return false;
2567 }
2568
2569 if ( in_array( $type, array( 'authentication_error', 'api_error', 'rate_limit_error', 'idempotency_error' ), true ) ) {
2570 return true;
2571 }
2572
2573 return 'invalid_request_error' === $type
2574 && in_array( $code, array( 'resource_missing', 'account_invalid', 'api_key_expired' ), true );
2575 }
2576
2577 /**
2578 * A renewal failed for a reason the customer cannot fix. Hold the
2579 * subscription exactly where it is — active, due, retried on the next
2580 * cron run — and tell the shop owner. Nothing is emailed to the
2581 * customer, and the status is deliberately NOT past_due: a key swap
2582 * would otherwise flip every subscription on the site at once.
2583 *
2584 * @param \WC_Order $parent Parent (subscription) order.
2585 * @param \WC_Order|null $renewal The pending renewal order.
2586 * @param string $reason Failure reason for the order note/log.
2587 * @return void
2588 */
2589 public static function hold_for_configuration( $parent, $renewal, $reason ) {
2590 $parent->add_order_note(
2591 sprintf(
2592 /* translators: %s: failure reason */
2593 __( 'Better Payment: the renewal could not be charged because of a Stripe configuration problem — %s. The subscription is unchanged and the renewal will be retried; the customer has not been contacted.', 'better-payment' ),
2594 $reason
2595 )
2596 );
2597
2598 if ( $renewal instanceof \WC_Order ) {
2599 self::remember_pending_renewal( $parent, $renewal );
2600 }
2601
2602 $parent->save();
2603
2604 OrderHandler::log( 'Subscription order #' . $parent->get_id() . ' renewal held — Stripe configuration problem: ' . $reason, 'error' );
2605
2606 /**
2607 * Fires when a renewal is held because Stripe is mis-configured.
2608 *
2609 * @since 2.4.0
2610 *
2611 * @param \WC_Order $parent The parent (subscription) order.
2612 * @param \WC_Order|null $renewal The pending renewal order.
2613 * @param string $reason Failure reason.
2614 */
2615 do_action( 'better_payment/woocommerce/subscription_renewal_held', $parent, $renewal, $reason );
2616 }
2617
2618 /**
2619 * Remember the renewal order that is awaiting payment, so the next cron
2620 * run charges (or re-invoices) THAT order instead of stacking a second
2621 * one beside it. Sets the meta on the parent without saving — callers
2622 * save as part of their own update.
2623 *
2624 * @param mixed $parent Parent (subscription) order.
2625 * @param mixed $renewal Pending renewal order.
2626 * @return void
2627 */
2628 public static function remember_pending_renewal( $parent, $renewal ) {
2629 if ( ! $parent instanceof \WC_Order || ! $renewal instanceof \WC_Order ) {
2630 return;
2631 }
2632
2633 $parent->update_meta_data( self::PENDING_RENEWAL_META, (string) $renewal->get_id() );
2634 }
2635
2636 /**
2637 * Cancel the subscription's outstanding renewal invoice, if it has one
2638 * and it is still unpaid, and forget it. The parent's own meta change is
2639 * left for the caller to save (both callers are mid-update).
2640 *
2641 * An unpaid renewal order keeps a working pay link in the customer's
2642 * inbox, so it has to be closed whenever the schedule it belongs to
2643 * stops applying — a cancellation, or a reactivation that rewrote the
2644 * due date. A renewal that was already PAID is never touched.
2645 *
2646 * @param mixed $parent Parent (subscription) order.
2647 * @param string $note Order note for the cancelled renewal.
2648 * @return void
2649 */
2650 public static function close_pending_renewal( $parent, $note ) {
2651 if ( ! $parent instanceof \WC_Order ) {
2652 return;
2653 }
2654
2655 $pending = self::pending_renewal_order( $parent );
2656
2657 if ( $pending instanceof \WC_Order ) {
2658 $pending->update_status( 'cancelled', (string) $note );
2659 }
2660
2661 $parent->delete_meta_data( self::PENDING_RENEWAL_META );
2662 }
2663
2664 /**
2665 * A renewal could not be charged: stop auto-charging (past_due) and, when
2666 * a pending renewal order exists, email it to the customer to pay
2667 * manually. Paying it reactivates the subscription (settle_renewal()).
2668 *
2669 * @param \WC_Order $parent Parent (subscription) order.
2670 * @param \WC_Order|null $renewal Pending renewal order, when one was created.
2671 * @param string $reason Failure reason for the order note/log.
2672 * @return void
2673 */
2674 public static function mark_past_due( $parent, $renewal, $reason ) {
2675 $parent->update_meta_data( self::STATUS_META, 'past_due' );
2676
2677 // The invoice the customer is about to be emailed is the outstanding
2678 // one: a later policy change must charge it, not open a second.
2679 if ( $renewal instanceof \WC_Order ) {
2680 self::remember_pending_renewal( $parent, $renewal );
2681 }
2682
2683 $parent->add_order_note(
2684 sprintf(
2685 /* translators: %s: failure reason */
2686 __( 'Better Payment: automatic renewal failed — %s. Automatic charging is paused until the pending renewal order is paid.', 'better-payment' ),
2687 $reason
2688 )
2689 );
2690 $parent->save();
2691
2692 OrderHandler::log( 'Subscription order #' . $parent->get_id() . ' set to past_due: ' . $reason, 'error' );
2693
2694 if ( $renewal instanceof \WC_Order ) {
2695 $renewal->add_order_note(
2696 sprintf(
2697 /* translators: %s: failure reason */
2698 __( 'Better Payment: the automatic charge failed — %s. The order was invoiced to the customer for manual payment.', 'better-payment' ),
2699 $reason
2700 )
2701 );
2702 $renewal->save();
2703
2704 self::send_customer_invoice( $renewal );
2705 }
2706 }
2707
2708 /**
2709 * A renewal is due but must be paid manually — because of the site-wide
2710 * manual renewal policy, the customer's auto-renew opt-out, or a missing
2711 * stored payment method. Creates the pending renewal order once and
2712 * emails it to the customer; paying it advances the schedule through
2713 * settle_renewal() exactly like an auto-charged renewal.
2714 *
2715 * Hourly-cron safe: while the previously invoiced renewal order is still
2716 * awaiting payment, nothing new is created or sent.
2717 *
2718 * @param \WC_Order $parent Parent (subscription) order.
2719 * @return void
2720 */
2721 public static function request_manual_renewal( $parent ) {
2722 $pending_id = (int) $parent->get_meta( self::PENDING_RENEWAL_META );
2723
2724 if ( $pending_id > 0 && function_exists( 'wc_get_order' ) ) {
2725 $pending = wc_get_order( $pending_id );
2726
2727 if ( $pending instanceof \WC_Order && $pending->needs_payment() ) {
2728 return; // Already invoiced — waiting on the customer.
2729 }
2730 }
2731
2732 $renewal = self::create_renewal_order( $parent );
2733
2734 if ( ! $renewal instanceof \WC_Order ) {
2735 OrderHandler::log( 'Could not create a manual renewal order for subscription order #' . $parent->get_id() . '.', 'error' );
2736 return;
2737 }
2738
2739 $renewal->add_order_note( __( 'Better Payment: manual subscription renewal — invoiced to the customer for payment.', 'better-payment' ) );
2740 $renewal->save();
2741
2742 $parent->update_meta_data( self::PENDING_RENEWAL_META, (string) $renewal->get_id() );
2743 $parent->add_order_note(
2744 sprintf(
2745 /* translators: %d: renewal order id */
2746 __( 'Better Payment: renewal due — renewal order #%d was invoiced to the customer for manual payment.', 'better-payment' ),
2747 $renewal->get_id()
2748 )
2749 );
2750 $parent->save();
2751
2752 self::send_customer_invoice( $renewal );
2753
2754 OrderHandler::log( 'Manual renewal order #' . $renewal->get_id() . ' invoiced for subscription order #' . $parent->get_id() . '.' );
2755
2756 /**
2757 * Fires after a manual renewal order is created and invoiced.
2758 *
2759 * @since 2.4.0
2760 *
2761 * @param \WC_Order $parent The parent (subscription) order.
2762 * @param \WC_Order $renewal The pending renewal order.
2763 */
2764 do_action( 'better_payment/woocommerce/subscription_manual_renewal_requested', $parent, $renewal );
2765 }
2766
2767 /**
2768 * Email a renewal order to its customer through WooCommerce's own
2769 * customer-invoice email (it carries the pay link).
2770 *
2771 * @param \WC_Order $renewal Renewal order.
2772 * @return void
2773 */
2774 public static function send_customer_invoice( $renewal ) {
2775 if ( ! function_exists( 'WC' ) ) {
2776 return;
2777 }
2778
2779 $emails = WC()->mailer()->get_emails();
2780 $invoice = isset( $emails['WC_Email_Customer_Invoice'] ) ? $emails['WC_Email_Customer_Invoice'] : null;
2781
2782 if ( $invoice instanceof \WC_Email_Customer_Invoice ) {
2783 $invoice->trigger( $renewal->get_id(), $renewal );
2784 }
2785 }
2786
2787 /* ---------------------------------------------------------------------
2788 * Customer auto-renewal toggle (My Account)
2789 * ------------------------------------------------------------------- */
2790
2791 /**
2792 * Automatic-renewal on/off control, rendered on the My Account
2793 * single-subscription view only ({@see MyAccount::render_view()}, context
2794 * 'subscription'), where the form carries a return flag so the handler
2795 * redirects back to the subscription page. It is deliberately not hooked
2796 * onto any order-details screen — see register().
2797 *
2798 * Rendered only when the Customer Auto-Renew Control setting allows it,
2799 * automatic renewal is enabled site-wide (otherwise every renewal is
2800 * manual and the toggle would do nothing), a reusable payment method is
2801 * stored, the subscription is live, and the viewer owns the order.
2802 *
2803 * @param mixed $order The order being viewed.
2804 * @param string $context 'subscription' (the only caller); '' omits the
2805 * return flag, redirecting to the order page.
2806 * @return void
2807 */
2808 public static function render_auto_renew_toggle( $order, $context = '' ) {
2809 if ( ! $order instanceof \WC_Order ) {
2810 return;
2811 }
2812
2813 if ( ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2814 return;
2815 }
2816
2817 if ( 'yes' !== self::setting( 'auto_renewal_toggle' ) || ! self::should_save_payment_method() ) {
2818 return;
2819 }
2820
2821 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2822 return;
2823 }
2824
2825 if ( '' === (string) $order->get_meta( self::CUSTOMER_META ) || '' === (string) $order->get_meta( self::PAYMENT_METHOD_META ) ) {
2826 return;
2827 }
2828
2829 $auto_on = 'no' !== (string) $order->get_meta( self::AUTO_RENEW_META );
2830
2831 ?>
2832 <form method="post" class="bp-subscription-auto-renew">
2833 <?php wp_nonce_field( 'bp_auto_renew_' . $order->get_id(), 'bp_auto_renew_nonce' ); ?>
2834 <input type="hidden" name="bp_auto_renew_order" value="<?php echo esc_attr( (string) $order->get_id() ); ?>">
2835 <input type="hidden" name="bp_auto_renew_value" value="<?php echo esc_attr( $auto_on ? 'no' : 'yes' ); ?>">
2836 <?php if ( 'subscription' === $context ) : ?>
2837 <input type="hidden" name="bp_return_subscription" value="1">
2838 <?php endif; ?>
2839 <p>
2840 <?php
2841 echo esc_html(
2842 $auto_on
2843 ? __( 'Automatic renewal is on — renewals are charged to your saved payment method.', 'better-payment' )
2844 : __( 'Automatic renewal is off — you will be emailed an invoice when each renewal is due.', 'better-payment' )
2845 );
2846 ?>
2847 </p>
2848 <button type="submit" class="button">
2849 <?php echo esc_html( $auto_on ? __( 'Turn off automatic renewal', 'better-payment' ) : __( 'Turn on automatic renewal', 'better-payment' ) ); ?>
2850 </button>
2851 </form>
2852 <?php
2853 }
2854
2855 /**
2856 * Handle the auto-renewal toggle submit. Nonce-guarded and restricted to
2857 * the order's own customer on a live subscription.
2858 *
2859 * @return void
2860 */
2861 public static function maybe_handle_auto_renew_toggle() {
2862 if ( empty( $_POST['bp_auto_renew_order'] ) || ! function_exists( 'wc_get_order' ) ) {
2863 return;
2864 }
2865
2866 $order_id = absint( wp_unslash( $_POST['bp_auto_renew_order'] ) );
2867 $nonce = isset( $_POST['bp_auto_renew_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bp_auto_renew_nonce'] ) ) : '';
2868
2869 if ( ! $order_id || ! wp_verify_nonce( $nonce, 'bp_auto_renew_' . $order_id ) ) {
2870 return;
2871 }
2872
2873 $order = wc_get_order( $order_id );
2874
2875 if ( ! $order || ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2876 return;
2877 }
2878
2879 if ( 'yes' !== self::setting( 'auto_renewal_toggle' ) ) {
2880 return;
2881 }
2882
2883 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2884 return;
2885 }
2886
2887 $enable = isset( $_POST['bp_auto_renew_value'] ) && 'yes' === sanitize_text_field( wp_unslash( $_POST['bp_auto_renew_value'] ) );
2888
2889 $order->update_meta_data( self::AUTO_RENEW_META, $enable ? 'yes' : 'no' );
2890 $order->add_order_note(
2891 $enable
2892 ? __( 'Better Payment: the customer turned automatic renewal ON — due renewals are charged to the saved payment method.', 'better-payment' )
2893 : __( 'Better Payment: the customer turned automatic renewal OFF — due renewals are invoiced for manual payment.', 'better-payment' )
2894 );
2895 $order->save();
2896
2897 OrderHandler::log( 'Customer set auto renewal ' . ( $enable ? 'on' : 'off' ) . ' for subscription order #' . $order->get_id() . '.' );
2898
2899 if ( function_exists( 'wc_add_notice' ) ) {
2900 wc_add_notice(
2901 $enable
2902 ? __( 'Automatic renewal is now on.', 'better-payment' )
2903 : __( 'Automatic renewal is now off. We will email you an invoice when a renewal is due.', 'better-payment' )
2904 );
2905 }
2906
2907 wp_safe_redirect( self::customer_action_redirect( $order ) );
2908 exit;
2909 }
2910
2911 /**
2912 * Where a customer-action handler sends the customer afterwards: back to
2913 * the My Account subscription view when the form came from there (the
2914 * `bp_return_subscription` flag — a flag, never a client-supplied URL),
2915 * otherwise the view-order page the shared forms have always used.
2916 * Only read after the action's nonce has been verified.
2917 *
2918 * @param \WC_Order $order Parent (subscription) order.
2919 * @return string
2920 */
2921 protected static function customer_action_redirect( $order ) {
2922 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- callers verify their own per-order nonce before this runs.
2923 if ( ! empty( $_POST['bp_return_subscription'] ) ) {
2924 $url = MyAccount::view_url( $order->get_id() );
2925
2926 if ( '' !== $url ) {
2927 return $url;
2928 }
2929 }
2930
2931 return $order->get_view_order_url();
2932 }
2933
2934 /* ---------------------------------------------------------------------
2935 * Cancellation
2936 * ------------------------------------------------------------------- */
2937
2938 /**
2939 * Offer the cancel action on parent orders with a live subscription.
2940 *
2941 * @param array $actions Order actions.
2942 * @param mixed $order The order being edited (newer WooCommerce passes it).
2943 * @return array
2944 */
2945 public static function register_order_action( $actions, $order = null ) {
2946 if ( ! $order instanceof \WC_Order && isset( $GLOBALS['theorder'] ) && $GLOBALS['theorder'] instanceof \WC_Order ) {
2947 $order = $GLOBALS['theorder'];
2948 }
2949
2950 if ( $order instanceof \WC_Order && in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2951 $actions['bp_cancel_subscription'] = __( 'Cancel Better Payment Subscription', 'better-payment' );
2952 }
2953
2954 return $actions;
2955 }
2956
2957 /**
2958 * Handle the admin order action.
2959 *
2960 * @param \WC_Order $order Parent (subscription) order.
2961 * @return void
2962 */
2963 public static function handle_cancel_action( $order ) {
2964 self::cancel( $order, '', 'admin' );
2965 }
2966
2967 /**
2968 * Customer-facing cancel button, rendered on the My Account
2969 * single-subscription view only ({@see MyAccount::render_view()}, context
2970 * 'subscription'), where the form carries a return flag so the handler
2971 * redirects back to the subscription page. It is deliberately not hooked
2972 * onto any order-details screen — see register().
2973 *
2974 * Rendered only when the product allowed user cancellation (snapshotted
2975 * on the parent order at activation), the subscription is live, and the
2976 * viewer is the order's own customer.
2977 *
2978 * @param mixed $order The order being viewed.
2979 * @param string $context 'subscription' (the only caller); '' omits the
2980 * return flag, redirecting to the order page.
2981 * @return void
2982 */
2983 public static function render_customer_cancel_button( $order, $context = '' ) {
2984 if ( ! $order instanceof \WC_Order ) {
2985 return;
2986 }
2987
2988 if ( ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2989 return;
2990 }
2991
2992 if ( 'yes' !== (string) $order->get_meta( self::USER_CANCEL_META ) ) {
2993 return;
2994 }
2995
2996 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2997 return;
2998 }
2999
3000 ?>
3001 <form method="post" class="bp-subscription-cancel">
3002 <?php wp_nonce_field( 'bp_cancel_subscription_' . $order->get_id(), 'bp_cancel_subscription_nonce' ); ?>
3003 <input type="hidden" name="bp_cancel_subscription_order" value="<?php echo esc_attr( (string) $order->get_id() ); ?>">
3004 <?php if ( 'subscription' === $context ) : ?>
3005 <input type="hidden" name="bp_return_subscription" value="1">
3006 <?php endif; ?>
3007 <button type="submit" class="button" onclick="return confirm( '<?php echo esc_js( __( 'Cancel this subscription? No further renewals will be charged.', 'better-payment' ) ); ?>' );">
3008 <?php esc_html_e( 'Cancel subscription', 'better-payment' ); ?>
3009 </button>
3010 </form>
3011 <?php
3012 }
3013
3014 /**
3015 * Handle the customer cancellation submit. Nonce-guarded and restricted
3016 * to the order's own customer on an order whose product allowed it.
3017 *
3018 * @return void
3019 */
3020 public static function maybe_handle_customer_cancel() {
3021 if ( empty( $_POST['bp_cancel_subscription_order'] ) || ! function_exists( 'wc_get_order' ) ) {
3022 return;
3023 }
3024
3025 $order_id = absint( wp_unslash( $_POST['bp_cancel_subscription_order'] ) );
3026 $nonce = isset( $_POST['bp_cancel_subscription_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bp_cancel_subscription_nonce'] ) ) : '';
3027
3028 if ( ! $order_id || ! wp_verify_nonce( $nonce, 'bp_cancel_subscription_' . $order_id ) ) {
3029 return;
3030 }
3031
3032 $order = wc_get_order( $order_id );
3033
3034 if ( ! $order || ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
3035 return;
3036 }
3037
3038 if ( 'yes' !== (string) $order->get_meta( self::USER_CANCEL_META ) ) {
3039 return;
3040 }
3041
3042 self::cancel( $order, __( 'Better Payment: subscription cancelled by the customer — no further automatic renewals will be charged.', 'better-payment' ), 'customer' );
3043
3044 if ( function_exists( 'wc_add_notice' ) ) {
3045 wc_add_notice( __( 'Your subscription has been cancelled.', 'better-payment' ) );
3046 }
3047
3048 wp_safe_redirect( self::customer_action_redirect( $order ) );
3049 exit;
3050 }
3051
3052 /**
3053 * Cancel a subscription. Terminal: renewals stop; already-created pending
3054 * renewal orders are left for the shop owner to keep or cancel.
3055 *
3056 * @param mixed $order Parent (subscription) order.
3057 * @param string $note Optional order-note override (e.g. the customer-
3058 * initiated cancellation message).
3059 * @param string $actor Who is cancelling: 'customer' | 'admin' | ''
3060 * (unknown — no actor stamp, the details view shows
3061 * no Cancelled By row).
3062 * @return void
3063 */
3064 public static function cancel( $order, $note = '', $actor = '' ) {
3065 if ( ! $order instanceof \WC_Order ) {
3066 return;
3067 }
3068
3069 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
3070 return;
3071 }
3072
3073 if ( in_array( $actor, array( 'customer', 'admin' ), true ) ) {
3074 $order->update_meta_data( self::CANCELLED_BY_TYPE_META, $actor );
3075 $order->update_meta_data( self::CANCELLED_BY_META, (string) get_current_user_id() );
3076 }
3077
3078 $order->update_meta_data( self::STATUS_META, 'cancelled' );
3079 $order->add_order_note( '' !== $note ? $note : __( 'Better Payment: subscription cancelled — no further automatic renewals will be charged.', 'better-payment' ) );
3080
3081 // An invoiced renewal is an open bill with a working pay link. Left
3082 // alone it outlives the subscription: the customer pays for a period
3083 // they cancelled, and settle_renewal() refuses to advance a
3084 // cancelled schedule — so the money buys nothing.
3085 self::close_pending_renewal( $order, __( 'Better Payment: cancelled with the subscription — this renewal is no longer due.', 'better-payment' ) );
3086
3087 $order->save();
3088
3089 OrderHandler::log( 'Subscription cancelled on order #' . $order->get_id() . '.' );
3090
3091 /**
3092 * Fires after a Better Payment subscription is cancelled.
3093 *
3094 * @since 2.4.0
3095 *
3096 * @param \WC_Order $order The parent (subscription) order.
3097 */
3098 do_action( 'better_payment/woocommerce/subscription_cancelled', $order );
3099 }
3100
3101 /**
3102 * Reactivate a cancelled (or past_due) subscription from the admin
3103 * Subscriptions tab. Sets the status back to active; when the stored
3104 * next-payment date already elapsed while the subscription was
3105 * cancelled, a fresh one is scheduled a full cycle from now — otherwise
3106 * the hourly cron would charge the customer the moment reactivation
3107 * saved.
3108 *
3109 * @param mixed $order Parent (subscription) order.
3110 * @param string $note Optional order-note override.
3111 * @return void
3112 */
3113 public static function reactivate( $order, $note = '' ) {
3114 if ( ! $order instanceof \WC_Order ) {
3115 return;
3116 }
3117
3118 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'cancelled', 'past_due' ), true ) ) {
3119 return;
3120 }
3121
3122 $next = (int) $order->get_meta( self::NEXT_PAYMENT_META );
3123
3124 if ( $next <= time() ) {
3125 $interval = max( 1, (int) $order->get_meta( self::INTERVAL_META ) );
3126 $period = self::sanitize_period( $order->get_meta( self::PERIOD_META ) );
3127 $next = self::next_payment_timestamp( $interval, $period );
3128 $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
3129 }
3130
3131 $order->update_meta_data( self::STATUS_META, 'active' );
3132 // The subscription is live again — a later cancellation must record
3133 // its own actor, never inherit this one's.
3134 $order->delete_meta_data( self::CANCELLED_BY_TYPE_META );
3135 $order->delete_meta_data( self::CANCELLED_BY_META );
3136
3137 // Reactivation just wrote a new due date, so an invoice raised
3138 // against the old one is stale. Leaving it payable means the next
3139 // renewal charge and that invoice can both be paid for one period.
3140 self::close_pending_renewal( $order, __( 'Better Payment: cancelled — the subscription was reactivated on a new schedule, so this renewal is no longer due.', 'better-payment' ) );
3141 $order->add_order_note(
3142 '' !== $note ? $note : sprintf(
3143 /* translators: %s: next renewal date */
3144 __( 'Better Payment: subscription reactivated. Next renewal: %s.', 'better-payment' ),
3145 date_i18n( get_option( 'date_format' ), $next )
3146 )
3147 );
3148 $order->save();
3149
3150 OrderHandler::log( 'Subscription reactivated on order #' . $order->get_id() . ' (next renewal ' . gmdate( 'Y-m-d H:i:s', $next ) . ' UTC).' );
3151
3152 /**
3153 * Fires after a Better Payment subscription is reactivated.
3154 *
3155 * @since 2.4.0
3156 *
3157 * @param \WC_Order $order The parent (subscription) order.
3158 */
3159 do_action( 'better_payment/woocommerce/subscription_reactivated', $order );
3160 }
3161
3162 /* ---------------------------------------------------------------------
3163 * Subscriber role sync
3164 * ------------------------------------------------------------------- */
3165
3166 /**
3167 * Subscription activated → give the customer the Subscriber Default
3168 * Role.
3169 *
3170 * @param mixed $order Parent (subscription) order.
3171 * @return void
3172 */
3173 public static function assign_active_role( $order ) {
3174 self::swap_customer_role( $order, self::setting( 'active_role' ), true );
3175 }
3176
3177 /**
3178 * Subscription cancelled/completed → give the customer the Subscriber
3179 * Inactive Role, unless they still hold another live subscription.
3180 *
3181 * @param mixed $order Parent (subscription) order.
3182 * @return void
3183 */
3184 public static function assign_inactive_role( $order ) {
3185 if ( ! $order instanceof \WC_Order ) {
3186 return;
3187 }
3188
3189 if ( self::customer_has_other_live_subscription( $order ) ) {
3190 return;
3191 }
3192
3193 self::swap_customer_role( $order, self::setting( 'inactive_role' ), false );
3194 }
3195
3196 /**
3197 * Pure: whether a user's current roles forbid the subscription role
3198 * swap. Site staff must never be demoted to a subscriber role by buying
3199 * or cancelling a subscription — set_role() REPLACES the user's roles.
3200 *
3201 * @param mixed $roles The user's current role slugs.
3202 * @return bool
3203 */
3204 public static function user_role_is_protected( $roles ) {
3205 /**
3206 * Filters the roles the subscription role sync refuses to replace.
3207 *
3208 * @since 2.4.0
3209 *
3210 * @param string[] $protected Protected role slugs.
3211 */
3212 $protected = apply_filters(
3213 'better_payment/woocommerce/role_sync_protected_roles',
3214 array( 'administrator', 'editor', 'shop_manager' )
3215 );
3216
3217 return (bool) array_intersect( (array) $roles, (array) $protected );
3218 }
3219
3220 /**
3221 * Replace the order's customer role for the subscription lifecycle.
3222 * No-ops on guest orders, unknown/blank target roles, protected users,
3223 * and users already holding the target role.
3224 *
3225 * @param mixed $order Parent (subscription) order.
3226 * @param string $role Target role slug (from the settings).
3227 * @param bool $active Whether the swap is for an activation (for the note).
3228 * @return void
3229 */
3230 public static function swap_customer_role( $order, $role, $active ) {
3231 if ( ! $order instanceof \WC_Order ) {
3232 return;
3233 }
3234
3235 $role = (string) $role;
3236
3237 if ( '' === $role || null === get_role( $role ) ) {
3238 return; // Unknown role — never assign a role that doesn't exist.
3239 }
3240
3241 $user_id = (int) $order->get_customer_id();
3242
3243 if ( $user_id <= 0 ) {
3244 return; // Guest order — no account to update.
3245 }
3246
3247 $user = get_user_by( 'id', $user_id );
3248
3249 if ( ! $user || self::user_role_is_protected( $user->roles ) ) {
3250 return;
3251 }
3252
3253 $granted = (string) get_user_meta( $user_id, self::GRANTED_ROLE_META, true );
3254
3255 if ( in_array( $role, (array) $user->roles, true ) ) {
3256 // Already there. Record it as ours only if we put it there — a
3257 // role the customer already had is not this module's to remove.
3258 if ( $granted === $role ) {
3259 return;
3260 }
3261
3262 if ( '' === $granted ) {
3263 return;
3264 }
3265 }
3266
3267 // ADD the subscription role and REMOVE only the one this module
3268 // granted last time. set_role() — what this used to call — replaces
3269 // every role the user has, so activating a subscription wiped a
3270 // membership plugin's role, a custom staff role, or plain `customer`,
3271 // and cancelling it "restored" whatever the settings happened to say
3272 // rather than what the customer actually had. Nothing outside this
3273 // module's own grant is touched now.
3274 if ( '' !== $granted && $granted !== $role && in_array( $granted, (array) $user->roles, true ) ) {
3275 $user->remove_role( $granted );
3276 }
3277
3278 if ( ! in_array( $role, (array) $user->roles, true ) ) {
3279 $user->add_role( $role );
3280 }
3281
3282 update_user_meta( $user_id, self::GRANTED_ROLE_META, $role );
3283
3284 $note_template = $active
3285 /* translators: %s: role slug */
3286 ? __( 'Better Payment: the customer was given the "%s" role — subscription active. Their other roles are unchanged.', 'better-payment' )
3287 /* translators: %s: role slug */
3288 : __( 'Better Payment: the customer was given the "%s" role — subscription ended. Their other roles are unchanged.', 'better-payment' );
3289
3290 $order->add_order_note( sprintf( $note_template, $role ) );
3291 $order->save();
3292
3293 OrderHandler::log( 'Customer #' . $user_id . ' given the ' . $role . ' role for subscription order #' . $order->get_id() . '.' );
3294 }
3295
3296 /**
3297 * Whether the order's customer holds another live (active or past_due)
3298 * subscription besides this one.
3299 *
3300 * @param \WC_Order $order Parent (subscription) order.
3301 * @return bool
3302 */
3303 public static function customer_has_other_live_subscription( $order ) {
3304 if ( ! function_exists( 'wc_get_orders' ) ) {
3305 return false;
3306 }
3307
3308 $customer_id = (int) $order->get_customer_id();
3309
3310 if ( $customer_id <= 0 ) {
3311 return false;
3312 }
3313
3314 $others = wc_get_orders(
3315 array(
3316 'limit' => 1,
3317 'type' => 'shop_order',
3318 'customer_id' => $customer_id,
3319 'exclude' => array( $order->get_id() ),
3320 'return' => 'ids',
3321 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 1), event-driven.
3322 array(
3323 'key' => self::STATUS_META,
3324 'value' => array( 'active', 'past_due' ),
3325 'compare' => 'IN',
3326 ),
3327 ),
3328 )
3329 );
3330
3331 return is_array( $others ) && count( $others ) > 0;
3332 }
3333
3334 /* ---------------------------------------------------------------------
3335 * Admin list (React admin → Subscriptions tab)
3336 * ------------------------------------------------------------------- */
3337
3338 /**
3339 * Flat display row for one subscription on the admin Subscriptions tab.
3340 *
3341 * Reads the parent (subscription) order and its `_bp_subscription_*`
3342 * meta. Safe to call whether or not WooCommerce is active — returns null
3343 * when WooCommerce (or the order) is unavailable, and the caller keeps
3344 * its un-hydrated base row.
3345 *
3346 * Dates are returned as site-local `Y-m-d H:i:s` strings (the subscription
3347 * filter compares `start_date` in site time), and the React admin renders
3348 * them as wall-clock times with `formatDateCompact()`. Note the transactions
3349 * table's `payment_date` is different: it is stored in UTC and rendered with
3350 * `formatDateTime()`, which converts it to the site timezone.
3351 *
3352 * @param int $subscription_id Parent (subscription) order id.
3353 * @return array|null
3354 * @since 2.4.0
3355 */
3356 public static function admin_list_row( $subscription_id ) {
3357 if ( ! function_exists( 'wc_get_order' ) ) {
3358 return null;
3359 }
3360
3361 $order = wc_get_order( absint( $subscription_id ) );
3362
3363 if ( ! $order instanceof \WC_Order ) {
3364 return null;
3365 }
3366
3367 $items = self::order_subscription_items( $order );
3368 $first_item = ! empty( $items ) ? current( $items ) : null;
3369 $product_name = $first_item instanceof \WC_Order_Item_Product ? $first_item->get_name() : '';
3370
3371 $customer_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
3372 if ( '' === $customer_name ) {
3373 $customer_name = trim( (string) $order->get_formatted_billing_full_name() );
3374 }
3375
3376 $next_payment = (int) $order->get_meta( self::NEXT_PAYMENT_META );
3377 $status = (string) $order->get_meta( self::STATUS_META );
3378 $created = $order->get_date_created();
3379
3380 return array(
3381 'customer_name' => $customer_name,
3382 'customer_email' => (string) $order->get_billing_email(),
3383 'product_name' => (string) $product_name,
3384 'amount' => (float) $order->get_total(),
3385 'currency' => (string) $order->get_currency(),
3386 'interval' => max( 1, (int) $order->get_meta( self::INTERVAL_META ) ),
3387 'period' => self::sanitize_period( $order->get_meta( self::PERIOD_META ) ),
3388 'status' => $status,
3389 'renewal_count' => (int) $order->get_meta( self::RENEWAL_COUNT_META ),
3390 // No next charge on a terminal subscription — send '' so the UI
3391 // shows an em dash instead of a stale date.
3392 'next_payment' => ( $next_payment > 0 && in_array( $status, array( 'active', 'past_due' ), true ) )
3393 ? wp_date( 'Y-m-d H:i:s', $next_payment )
3394 : '',
3395 'start_date' => $created ? $created->date( 'Y-m-d H:i:s' ) : '',
3396 'order_edit_url' => (string) $order->get_edit_order_url(),
3397 );
3398 }
3399
3400 /**
3401 * Light status lookup for one subscription — just the raw
3402 * `_bp_subscription_status` meta, lowercased, with no row hydration.
3403 * Used by the admin Subscriptions tab's summary counts, which need
3404 * every subscription's status but nothing else from the order.
3405 *
3406 * @param int $subscription_id Parent (subscription) order id.
3407 * @return string Raw status ('' when WooCommerce or the order is gone).
3408 * @since 2.4.0
3409 */
3410 public static function admin_status( $subscription_id ) {
3411 if ( ! function_exists( 'wc_get_order' ) ) {
3412 return '';
3413 }
3414
3415 $order = wc_get_order( absint( $subscription_id ) );
3416
3417 if ( ! $order instanceof \WC_Order ) {
3418 return '';
3419 }
3420
3421 return strtolower( (string) $order->get_meta( self::STATUS_META ) );
3422 }
3423
3424 /**
3425 * Pure: the status actions the admin Subscriptions details view may
3426 * offer for a subscription in the given status. Status transitions are
3427 * the ONLY thing editable — everything else is read-only.
3428 *
3429 * `completed` is terminal (the subscription's schedule ended;
3430 * reactivating would restart a schedule the customer finished paying),
3431 * and an empty/unknown status (un-hydrated row, integration gone)
3432 * offers nothing.
3433 *
3434 * @param string $status Raw `_bp_subscription_status` meta value.
3435 * @return string[] Zero or more of 'cancel' | 'reactivate'.
3436 */
3437 public static function available_admin_actions( $status ) {
3438 $map = array(
3439 'active' => array( 'cancel' ),
3440 'past_due' => array( 'cancel', 'reactivate' ),
3441 'cancelled' => array( 'reactivate' ),
3442 );
3443
3444 $status = strtolower( (string) $status );
3445
3446 return isset( $map[ $status ] ) ? $map[ $status ] : array();
3447 }
3448
3449 /**
3450 * Extended display data for one subscription on the admin details view:
3451 * the list row plus the fields only the details page shows. Returns null
3452 * when WooCommerce (or the order) is unavailable, same contract as
3453 * admin_list_row().
3454 *
3455 * @param int $subscription_id Parent (subscription) order id.
3456 * @return array|null
3457 * @since 2.4.0
3458 */
3459 public static function admin_detail( $subscription_id ) {
3460 $row = self::admin_list_row( $subscription_id );
3461
3462 if ( null === $row ) {
3463 return null;
3464 }
3465
3466 $order = wc_get_order( absint( $subscription_id ) );
3467
3468 if ( ! $order instanceof \WC_Order ) {
3469 return null;
3470 }
3471
3472 // Effective state, not the raw AUTO_RENEW_META flag: the site-wide
3473 // renewal policy AND the customer's own preference — the same test
3474 // renew() applies. Reporting the flag alone showed "On" while a
3475 // manual policy meant every renewal was actually invoiced.
3476 $row['auto_renew'] = self::auto_renewal_enabled_for( $order ) ? 'yes' : 'no';
3477 $row['available_actions'] = self::available_admin_actions( $row['status'] );
3478
3479 // When the most recent renewal payment settled — '' until the first
3480 // renewal, and the UI drops the row entirely rather than showing an
3481 // empty value (same treatment next_payment gets on a terminal
3482 // subscription).
3483 $last_payment = self::last_payment_timestamp( $order );
3484 $row['last_payment'] = $last_payment > 0 ? wp_date( 'Y-m-d H:i:s', $last_payment ) : '';
3485
3486 // Who cancelled the subscription. Only reported while the status is
3487 // actually cancelled AND cancel() stamped an actor — pre-stamp
3488 // cancellations have no answer, and the UI drops the row rather than
3489 // guessing.
3490 $row['cancelled_by_type'] = '';
3491 $row['cancelled_by_name'] = '';
3492
3493 if ( 'cancelled' === $row['status'] ) {
3494 $actor = (string) $order->get_meta( self::CANCELLED_BY_TYPE_META );
3495
3496 if ( in_array( $actor, array( 'customer', 'admin' ), true ) ) {
3497 $row['cancelled_by_type'] = $actor;
3498 $row['cancelled_by_name'] = self::cancelled_by_name( $order, $actor );
3499 }
3500 }
3501
3502 // Billing & Shipping card. Plain-text lines, not WooCommerce's
3503 // formatted-address HTML — the React admin renders text children
3504 // only, never markup.
3505 $row['billing_address'] = self::address_lines( $order->get_formatted_billing_address() );
3506 $row['shipping_address'] = self::address_lines( $order->get_formatted_shipping_address() );
3507 $row['billing_phone'] = (string) $order->get_billing_phone();
3508
3509 return $row;
3510 }
3511
3512 /**
3513 * Display name of the user who cancelled the subscription. Resolved live
3514 * from the stamped user id so a renamed account reads current; when that
3515 * user is gone (or the stamp predates a user id), a customer
3516 * cancellation still has the order's own billing name to fall back on —
3517 * an admin one does not, and returns '' (the UI shows the actor tag
3518 * alone).
3519 *
3520 * @param \WC_Order $order Parent (subscription) order.
3521 * @param string $actor 'customer' | 'admin'.
3522 * @return string
3523 * @since 2.4.0
3524 */
3525 public static function cancelled_by_name( $order, $actor ) {
3526 $user_id = (int) $order->get_meta( self::CANCELLED_BY_META );
3527 $user = $user_id > 0 ? get_userdata( $user_id ) : false;
3528
3529 if ( $user && '' !== trim( (string) $user->display_name ) ) {
3530 return (string) $user->display_name;
3531 }
3532
3533 if ( 'customer' === $actor ) {
3534 $billing = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
3535
3536 if ( '' !== $billing ) {
3537 return $billing;
3538 }
3539 }
3540
3541 return '';
3542 }
3543
3544 /**
3545 * When the subscription's most recent renewal payment settled, as a UNIX
3546 * timestamp — 0 when it has never renewed (the initial checkout is the
3547 * Started date, not a renewal payment). Reads the LAST_PAYMENT_META stamp
3548 * settle_renewal() writes; subscriptions renewed before the stamp existed
3549 * fall back to the newest paid renewal order's paid date.
3550 *
3551 * @param \WC_Order $order Parent (subscription) order.
3552 * @return int
3553 * @since 2.4.0
3554 */
3555 public static function last_payment_timestamp( $order ) {
3556 $stamped = (int) $order->get_meta( self::LAST_PAYMENT_META );
3557
3558 if ( $stamped > 0 ) {
3559 return $stamped;
3560 }
3561
3562 if ( (int) $order->get_meta( self::RENEWAL_COUNT_META ) < 1 ) {
3563 return 0;
3564 }
3565
3566 $renewals = wc_get_orders(
3567 array(
3568 'limit' => 1,
3569 'type' => 'shop_order',
3570 'status' => array( 'wc-processing', 'wc-completed' ),
3571 'orderby' => 'date',
3572 'order' => 'DESC',
3573 'meta_key' => self::RENEWAL_PARENT_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- bounded (limit 1), details-view only.
3574 'meta_value' => (string) $order->get_id(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- see above.
3575 )
3576 );
3577
3578 $renewal = is_array( $renewals ) && ! empty( $renewals ) ? current( $renewals ) : null;
3579
3580 if ( ! $renewal instanceof \WC_Order ) {
3581 return 0;
3582 }
3583
3584 $paid = $renewal->get_date_paid();
3585
3586 if ( ! $paid ) {
3587 $paid = $renewal->get_date_created();
3588 }
3589
3590 return $paid ? $paid->getTimestamp() : 0;
3591 }
3592
3593 /**
3594 * Pure: a WooCommerce formatted address (lines joined with <br/>) as an
3595 * array of plain-text lines. Empty/whitespace lines are dropped; an
3596 * empty/absent address yields an empty array (the UI renders an em dash).
3597 *
3598 * @param mixed $formatted The formatted address HTML ('' when unset).
3599 * @return string[]
3600 * @since 2.4.0
3601 */
3602 public static function address_lines( $formatted ) {
3603 if ( ! is_string( $formatted ) || '' === trim( $formatted ) ) {
3604 return array();
3605 }
3606
3607 $lines = preg_split( '#<br\s*/?>#i', $formatted );
3608 $clean = array();
3609
3610 foreach ( (array) $lines as $line ) {
3611 $line = trim( wp_strip_all_tags( (string) $line ) );
3612
3613 if ( '' !== $line ) {
3614 $clean[] = $line;
3615 }
3616 }
3617
3618 return $clean;
3619 }
3620
3621 /**
3622 * Display row for one related order on the admin details view's
3623 * Related Orders list. Returns null when WooCommerce (or the order) is
3624 * unavailable — the caller keeps its un-hydrated base row.
3625 *
3626 * @param int $order_id Related (parent or renewal) order id.
3627 * @return array|null
3628 * @since 2.4.0
3629 */
3630 public static function admin_order_row( $order_id ) {
3631 if ( ! function_exists( 'wc_get_order' ) ) {
3632 return null;
3633 }
3634
3635 $order = wc_get_order( absint( $order_id ) );
3636
3637 if ( ! $order instanceof \WC_Order ) {
3638 return null;
3639 }
3640
3641 $created = $order->get_date_created();
3642 $status = (string) $order->get_status();
3643
3644 return array(
3645 'order_number' => (string) $order->get_order_number(),
3646 'date' => $created ? $created->date( 'Y-m-d H:i:s' ) : '',
3647 'status' => $status,
3648 'status_label' => function_exists( 'wc_get_order_status_name' ) ? wc_get_order_status_name( $status ) : ucfirst( $status ),
3649 'total' => (float) $order->get_total(),
3650 'currency' => (string) $order->get_currency(),
3651 'edit_url' => (string) $order->get_edit_order_url(),
3652 );
3653 }
3654
3655 /**
3656 * Perform an admin status action (cancel | reactivate) on a
3657 * subscription. Used by the admin Subscriptions REST endpoint.
3658 *
3659 * @param int $subscription_id Parent (subscription) order id.
3660 * @param string $action 'cancel' or 'reactivate'.
3661 * @return true|\WP_Error
3662 * @since 2.4.0
3663 */
3664 public static function admin_status_action( $subscription_id, $action ) {
3665 if ( ! function_exists( 'wc_get_order' ) ) {
3666 return new \WP_Error( 'woocommerce_inactive', __( 'WooCommerce is not active, so this subscription cannot be managed.', 'better-payment' ), array( 'status' => 400 ) );
3667 }
3668
3669 $order = wc_get_order( absint( $subscription_id ) );
3670
3671 if ( ! $order instanceof \WC_Order ) {
3672 return new \WP_Error( 'subscription_not_found', __( 'Subscription order not found.', 'better-payment' ), array( 'status' => 404 ) );
3673 }
3674
3675 $status = (string) $order->get_meta( self::STATUS_META );
3676
3677 if ( ! in_array( $action, self::available_admin_actions( $status ), true ) ) {
3678 return new \WP_Error( 'action_not_available', __( 'This action is not available for the subscription\'s current status.', 'better-payment' ), array( 'status' => 400 ) );
3679 }
3680
3681 if ( 'cancel' === $action ) {
3682 self::cancel( $order, __( 'Better Payment: subscription cancelled from the Better Payment admin — no further automatic renewals will be charged.', 'better-payment' ), 'admin' );
3683 } else {
3684 self::reactivate( $order );
3685 }
3686
3687 return true;
3688 }
3689 }
3690