PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
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.0, at includes/WooCommerce/Subscriptions.php

3,687 lines 153.8 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 'payment_date' => current_time( 'mysql' ),
1796 'source' => 'stripe',
1797 'transaction_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '',
1798 'customer_info' => maybe_serialize( $intent ),
1799 'form_fields_info' => maybe_serialize( $form_fields_info ),
1800 'obj_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '',
1801 'status' => $status,
1802 'currency' => (string) $data['currency'],
1803 'referer' => 'woocommerce',
1804 'campaign_id' => '',
1805 );
1806 }
1807
1808 /* ---------------------------------------------------------------------
1809 * Activation + settlement
1810 * ------------------------------------------------------------------- */
1811
1812 /**
1813 * An order reached a paid status without the gateway return telling us.
1814 *
1815 * The completion contract used to be reachable only from the Stripe
1816 * return (and from the cron charge), so a subscription order an admin
1817 * marked complete by hand — or an invoiced renewal settled offline and
1818 * ticked off in wp-admin — activated nothing and advanced nothing, while
1819 * the cron went on charging the card for a period the customer had
1820 * already paid. Both handlers on the contract are idempotent (the
1821 * subscription's status meta; the renewal's settled meta), so an order
1822 * that DID come through the return path changes nothing here.
1823 *
1824 * @param mixed $order_id The order that changed status.
1825 * @return void
1826 */
1827 public static function maybe_complete_from_status( $order_id ) {
1828 if ( ! function_exists( 'wc_get_order' ) ) {
1829 return;
1830 }
1831
1832 $order = wc_get_order( $order_id );
1833
1834 if ( ! $order instanceof \WC_Order || $order->needs_payment() ) {
1835 return;
1836 }
1837
1838 $is_renewal = (int) $order->get_meta( self::RENEWAL_PARENT_META ) > 0;
1839
1840 // Already activated: nothing to do, and worth answering from meta
1841 // before touching the line items — this hook fires for every order on
1842 // the site, including one an admin re-completes.
1843 if ( ! $is_renewal && '' !== (string) $order->get_meta( self::STATUS_META ) ) {
1844 return;
1845 }
1846
1847 if ( ! $is_renewal && ! self::order_contains_subscription( $order ) ) {
1848 return; // Ordinary purchase — the overwhelming majority.
1849 }
1850
1851 /**
1852 * Fires for a Better Payment subscription order that became paid
1853 * outside the gateway return. Same contract, same consumers.
1854 *
1855 * @since 2.4.0
1856 *
1857 * @param \WC_Order $order The paid order.
1858 * @param object|null $row The order's Better Payment transaction row, when it has one.
1859 */
1860 do_action( 'better_payment/woocommerce/payment_complete', $order, self::transaction_row( $order ) );
1861 }
1862
1863 /**
1864 * The Better Payment transaction row an order was paid with, or null.
1865 *
1866 * The status bridge above must pass it, and this is not a nicety: the
1867 * engine's own consumer marks the order paid with `payment_complete()`
1868 * and fires the module's hook *after* that, so the status transition —
1869 * and therefore the bridge — runs FIRST. Passing null there would
1870 * activate the subscription with no row, `harvest_payment_method()`
1871 * would find no Checkout Session to read, and every subscription bought
1872 * through the normal checkout would end up with no reusable payment
1873 * method: correct-looking, and silently invoiced by hand forever after.
1874 * The row is already written and confirmed by then, so reading it back is
1875 * the whole fix. An order with no row (a $0 trial under a manual renewal
1876 * policy, an offline payment) legitimately returns null.
1877 *
1878 * @param mixed $order The order.
1879 * @return object|null
1880 */
1881 public static function transaction_row( $order ) {
1882 if ( ! $order instanceof \WC_Order ) {
1883 return null;
1884 }
1885
1886 $row_id = (int) $order->get_meta( '_bp_transaction_id' );
1887
1888 if ( $row_id < 1 ) {
1889 return null;
1890 }
1891
1892 $row = DB::get_transaction( $row_id );
1893
1894 return empty( $row->id ) ? null : $row;
1895 }
1896
1897 /**
1898 * Consume the module's payment-complete hook. A first payment on a
1899 * subscription cart activates the subscription; a paid renewal order
1900 * (cron-charged or manually paid) advances the parent's schedule.
1901 *
1902 * @param mixed $order The paid order.
1903 * @param object|null $row The Better Payment transaction row.
1904 * @return void
1905 */
1906 public static function on_order_paid( $order, $row ) {
1907 if ( ! $order instanceof \WC_Order ) {
1908 return;
1909 }
1910
1911 $parent_id = (int) $order->get_meta( self::RENEWAL_PARENT_META );
1912
1913 if ( $parent_id > 0 ) {
1914 self::settle_renewal( $order, $parent_id );
1915 return;
1916 }
1917
1918 if ( '' !== (string) $order->get_meta( self::STATUS_META ) ) {
1919 return; // Already activated (duplicate confirmation).
1920 }
1921
1922 if ( ! self::order_contains_subscription( $order ) ) {
1923 return; // Ordinary one-off purchase — the common case.
1924 }
1925
1926 self::activate_subscription( $order, $row );
1927 }
1928
1929 /**
1930 * Activate a subscription on its parent order: snapshot the schedule,
1931 * save the reusable Stripe customer + payment method, set the first
1932 * renewal date.
1933 *
1934 * @param \WC_Order $order Parent order.
1935 * @param object|null $row Better Payment transaction row.
1936 * @return void
1937 */
1938 public static function activate_subscription( $order, $row ) {
1939 $items = self::order_subscription_items( $order );
1940
1941 if ( empty( $items ) ) {
1942 return;
1943 }
1944
1945 // The schedule (and the trial / cancellation policies) come from the
1946 // first subscription item, snapshotted at activation so a later
1947 // product edit never rewrites a customer's agreed terms (one
1948 // schedule per order — documented limitation).
1949 $product = $items[0]->get_product();
1950 $interval = $product ? max( 1, (int) $product->get_meta( self::PRODUCT_INTERVAL_META ) ) : 1;
1951 $period = self::sanitize_period( $product ? $product->get_meta( self::PRODUCT_PERIOD_META ) : 'month' );
1952 $trial_days = $product ? max( 0, (int) $product->get_meta( self::PRODUCT_TRIAL_DAYS_META ) ) : 0;
1953 $user_cancel = ( $product && 'yes' === $product->get_meta( self::USER_CANCEL_META ) ) ? 'yes' : 'no';
1954
1955 // The trial was applied iff the customer actually checked out at $0
1956 // for this item (zero_trial_price() zeroed the line during cart
1957 // totals calculation — the zeroed line IS the marker). A trial
1958 // product bought by an ineligible returning subscriber has a paid
1959 // line and renews on the plain schedule.
1960 $trial_applied = $trial_days > 0 && (float) $items[0]->get_total() < 0.01;
1961
1962 // Trial → the first payment is due when the trial ends (nothing was
1963 // paid at checkout). No trial → the paid first cycle just started,
1964 // next payment one cycle out.
1965 $next = $trial_applied
1966 ? self::trial_end_timestamp( $trial_days )
1967 : self::next_payment_timestamp( $interval, $period );
1968
1969 $harvested = self::harvest_payment_method( $order, $row );
1970
1971 $order->update_meta_data( self::STATUS_META, 'active' );
1972 $order->update_meta_data( self::INTERVAL_META, (string) $interval );
1973 $order->update_meta_data( self::PERIOD_META, $period );
1974 $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
1975 $order->update_meta_data( self::USER_CANCEL_META, $user_cancel );
1976 $order->update_meta_data( self::RENEWAL_COUNT_META, '0' );
1977 // What the customer agreed to pay per cycle. A trial line checked out
1978 // at $0, so the snapshot takes the product's price in that case —
1979 // the trial is free, the subscription is not.
1980 $order->update_meta_data( self::PRICE_SNAPSHOT_META, self::renewal_line_snapshot( $items, $trial_applied ) );
1981
1982 if ( $trial_applied ) {
1983 $order->update_meta_data( self::TRIAL_APPLIED_META, 'yes' );
1984 }
1985
1986 $trial_note = '';
1987 if ( $trial_applied ) {
1988 $trial_note = ' ' . sprintf(
1989 /* translators: %d: number of free trial days */
1990 _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' ),
1991 $trial_days
1992 );
1993 } elseif ( $trial_days > 0 ) {
1994 $trial_note = ' ' . __( 'The product offers a free trial but this customer already used one for it, so the regular schedule applies.', 'better-payment' );
1995 }
1996
1997 $order->add_order_note(
1998 sprintf(
1999 /* translators: 1: billing schedule, 2: next renewal date, 3: note about the saved payment method, 4: trial note (may be empty) */
2000 __( 'Better Payment: subscription activated — renews every %1$s. Next renewal: %2$s. %3$s%4$s', 'better-payment' ),
2001 self::describe_schedule( $interval, $period ),
2002 date_i18n( get_option( 'date_format' ), $next ),
2003 $harvested
2004 ? __( 'A reusable payment method was saved for automatic renewals.', 'better-payment' )
2005 : __( 'No reusable payment method could be saved — renewals will need manual payment.', 'better-payment' ),
2006 $trial_note
2007 )
2008 );
2009 $order->save();
2010
2011 // Relation table: the order that started the subscription.
2012 self::record_order_relation( $order->get_id(), $order, SubscriptionRelationModel::TYPE_NEW );
2013
2014 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' ) . ').' );
2015
2016 /**
2017 * Fires after a Better Payment subscription is activated on an order.
2018 *
2019 * @since 2.4.0
2020 *
2021 * @param \WC_Order $order The parent (subscription) order.
2022 */
2023 do_action( 'better_payment/woocommerce/subscription_activated', $order );
2024 }
2025
2026 /**
2027 * Record a row in the e-commerce subscription <-> order relation table
2028 * (source 'woo'). `$subscription_id` is the parent (subscription) order
2029 * id; `$order` is the related order — the parent itself for TYPE_NEW, a
2030 * renewal order for TYPE_RENEW. The model dedupes, so re-recording the
2031 * same relation is harmless.
2032 *
2033 * @param int $subscription_id Parent (subscription) order id.
2034 * @param mixed $order The related order (non-orders are rejected).
2035 * @param string $type SubscriptionRelationModel::TYPE_NEW|TYPE_RENEW.
2036 * @return void
2037 */
2038 public static function record_order_relation( $subscription_id, $order, $type ) {
2039 if ( ! $order instanceof \WC_Order ) {
2040 return;
2041 }
2042
2043 $items = self::order_subscription_items( $order );
2044 $order_item_id = ! empty( $items ) ? (int) $items[0]->get_id() : 0;
2045
2046 SubscriptionRelationModel::record(
2047 array(
2048 'subscription_id' => (int) $subscription_id,
2049 'order_id' => $order->get_id(),
2050 'order_item_id' => $order_item_id,
2051 'type' => $type,
2052 'source' => SubscriptionRelationModel::SOURCE_WOO,
2053 )
2054 );
2055 }
2056
2057 /**
2058 * Save the reusable Stripe customer + payment method from the verified
2059 * Checkout Session onto the order.
2060 *
2061 * @param \WC_Order $order Parent order.
2062 * @param object|null $row Better Payment transaction row (obj_id = session id).
2063 * @return bool Whether both identifiers are now stored.
2064 */
2065 public static function harvest_payment_method( $order, $row ) {
2066 if ( '' !== (string) $order->get_meta( self::CUSTOMER_META ) && '' !== (string) $order->get_meta( self::PAYMENT_METHOD_META ) ) {
2067 return true;
2068 }
2069
2070 $session_id = isset( $row->obj_id ) ? (string) $row->obj_id : '';
2071
2072 if ( '' === $session_id || 0 !== strpos( $session_id, 'cs_' ) ) {
2073 return false;
2074 }
2075
2076 $keys = StripeService::get_global_keys();
2077
2078 if ( empty( $keys['secret_key'] ) ) {
2079 return false;
2080 }
2081
2082 // Expand both intent kinds: a paid checkout saved the card on its
2083 // PaymentIntent; a $0 free-trial checkout saved it on the setup-mode
2084 // session's SetupIntent.
2085 $session = StripeService::retrieve_checkout_session( $session_id, $keys['secret_key'], array( 'payment_intent', 'setup_intent' ) );
2086
2087 if ( is_wp_error( $session ) ) {
2088 OrderHandler::log( 'Could not retrieve session ' . $session_id . ' to save the reusable payment method for order #' . $order->get_id() . ': ' . $session->get_error_message(), 'error' );
2089 return false;
2090 }
2091
2092 $customer = '';
2093 if ( ! empty( $session->customer ) ) {
2094 $customer = is_object( $session->customer ) && ! empty( $session->customer->id ) ? (string) $session->customer->id : (string) $session->customer;
2095 }
2096
2097 $payment_method = '';
2098 if ( ! empty( $session->payment_intent ) && is_object( $session->payment_intent ) && ! empty( $session->payment_intent->payment_method ) ) {
2099 $pm = $session->payment_intent->payment_method;
2100 $payment_method = is_object( $pm ) && ! empty( $pm->id ) ? (string) $pm->id : (string) $pm;
2101 }
2102
2103 if ( '' === $payment_method && ! empty( $session->setup_intent ) && is_object( $session->setup_intent ) && ! empty( $session->setup_intent->payment_method ) ) {
2104 $pm = $session->setup_intent->payment_method;
2105 $payment_method = is_object( $pm ) && ! empty( $pm->id ) ? (string) $pm->id : (string) $pm;
2106 }
2107
2108 if ( '' === $customer || '' === $payment_method ) {
2109 return false;
2110 }
2111
2112 $order->update_meta_data( self::CUSTOMER_META, sanitize_text_field( $customer ) );
2113 $order->update_meta_data( self::PAYMENT_METHOD_META, sanitize_text_field( $payment_method ) );
2114
2115 return true;
2116 }
2117
2118 /**
2119 * A renewal order was paid — advance the parent's schedule and (re)set
2120 * it to active. This is also the past_due recovery path: manually paying
2121 * the invoiced renewal order lands here through the same hook.
2122 *
2123 * @param mixed $renewal_order The paid renewal order (anything else is rejected).
2124 * @param mixed $parent_id Parent (subscription) order id.
2125 * @return void
2126 */
2127 public static function settle_renewal( $renewal_order, $parent_id ) {
2128 if ( ! $renewal_order instanceof \WC_Order ) {
2129 return;
2130 }
2131
2132 // Settling advances the schedule a whole cycle, so it happens once
2133 // per renewal order. The same payment can arrive twice — the gateway
2134 // return and the order-status bridge both lead here — and the second
2135 // pass would give the customer a free billing period.
2136 if ( 'yes' === (string) $renewal_order->get_meta( self::RENEWAL_SETTLED_META ) ) {
2137 return;
2138 }
2139
2140 $renewal_order->update_meta_data( self::RENEWAL_SETTLED_META, 'yes' );
2141 $renewal_order->save();
2142
2143 $parent = function_exists( 'wc_get_order' ) ? wc_get_order( $parent_id ) : false;
2144
2145 if ( ! $parent ) {
2146 OrderHandler::log( 'Renewal order #' . $renewal_order->get_id() . ' paid but parent order #' . $parent_id . ' was not found.', 'error' );
2147 return;
2148 }
2149
2150 if ( 'cancelled' === (string) $parent->get_meta( self::STATUS_META ) ) {
2151 $parent->add_order_note(
2152 sprintf(
2153 /* translators: %d: renewal order id */
2154 __( 'Better Payment: renewal order #%d was paid but the subscription is cancelled — the schedule was not advanced.', 'better-payment' ),
2155 $renewal_order->get_id()
2156 )
2157 );
2158 $parent->save();
2159 return;
2160 }
2161
2162 $renewal_count = (int) $parent->get_meta( self::RENEWAL_COUNT_META ) + 1;
2163
2164 $parent->update_meta_data( self::RENEWAL_COUNT_META, (string) $renewal_count );
2165 $parent->update_meta_data( self::LAST_PAYMENT_META, (string) time() );
2166 // A paid renewal clears the outstanding manual-renewal invoice guard
2167 // (no-op for auto-charged renewals).
2168 $parent->delete_meta_data( self::PENDING_RENEWAL_META );
2169
2170 $interval = max( 1, (int) $parent->get_meta( self::INTERVAL_META ) );
2171 $period = self::sanitize_period( $parent->get_meta( self::PERIOD_META ) );
2172 $next = self::next_payment_timestamp( $interval, $period );
2173
2174 $parent->update_meta_data( self::STATUS_META, 'active' );
2175 $parent->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
2176 $parent->add_order_note(
2177 sprintf(
2178 /* translators: 1: renewal order id, 2: next renewal date */
2179 __( 'Better Payment: renewal order #%1$d paid. Next renewal: %2$s.', 'better-payment' ),
2180 $renewal_order->get_id(),
2181 date_i18n( get_option( 'date_format' ), $next )
2182 )
2183 );
2184 $parent->save();
2185
2186 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.' );
2187
2188 /**
2189 * Fires after a subscription renewal payment settles.
2190 *
2191 * @since 2.4.0
2192 *
2193 * @param \WC_Order $parent The parent (subscription) order.
2194 * @param \WC_Order $renewal_order The paid renewal order.
2195 */
2196 do_action( 'better_payment/woocommerce/subscription_renewed', $parent, $renewal_order );
2197 }
2198
2199 /* ---------------------------------------------------------------------
2200 * Renewal processing (cron)
2201 * ------------------------------------------------------------------- */
2202
2203 /**
2204 * Charge every due subscription. Runs hourly via WP-Cron.
2205 *
2206 * @return void
2207 */
2208 public static function process_due_subscriptions() {
2209 if ( ! function_exists( 'wc_get_orders' ) || ! StripeService::is_configured() ) {
2210 return;
2211 }
2212
2213 /**
2214 * Filters how many due subscriptions one cron run charges. Each one
2215 * is a Stripe round-trip, so the batch is bounded; a shop with more
2216 * due subscriptions than this catches up over the following runs.
2217 *
2218 * @since 2.4.0
2219 *
2220 * @param int $limit Subscriptions per run.
2221 */
2222 $limit = (int) apply_filters( 'better_payment/woocommerce/renewal_batch_size', 20 );
2223
2224 /**
2225 * Filters the parent-order statuses a due subscription is renewed
2226 * from. A subscription order parked in any other status (on-hold,
2227 * refunded, a custom status) is skipped in silence, which is why
2228 * this is filterable rather than fixed.
2229 *
2230 * @since 2.4.0
2231 *
2232 * @param string[] $statuses Order statuses.
2233 */
2234 $statuses = (array) apply_filters( 'better_payment/woocommerce/renewal_parent_statuses', array( 'wc-processing', 'wc-completed' ) );
2235
2236 $orders = wc_get_orders(
2237 array(
2238 'limit' => max( 1, $limit ),
2239 'type' => 'shop_order',
2240 'status' => $statuses,
2241 'return' => 'objects',
2242 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 20), hourly cron.
2243 array(
2244 'key' => self::STATUS_META,
2245 'value' => 'active',
2246 ),
2247 array(
2248 'key' => self::NEXT_PAYMENT_META,
2249 'value' => time(),
2250 'compare' => '<=',
2251 'type' => 'NUMERIC',
2252 ),
2253 ),
2254 )
2255 );
2256
2257 if ( empty( $orders ) || ! is_array( $orders ) ) {
2258 return;
2259 }
2260
2261 foreach ( $orders as $order ) {
2262 self::renew( $order );
2263 }
2264 }
2265
2266 /**
2267 * Create and charge one renewal for a due subscription.
2268 *
2269 * On success the renewal order is marked paid, the payment is recorded in
2270 * the Better Payment transactions table, and the parent's schedule
2271 * advances (through the shared payment-complete hook). On failure the
2272 * subscription goes past_due: auto-charging stops and the customer is
2273 * emailed the pending renewal order to pay manually.
2274 *
2275 * @param \WC_Order $parent Parent (subscription) order.
2276 * @return void
2277 */
2278 public static function renew( $parent ) {
2279 // Idempotency guard — a slow Stripe call must not let an overlapping
2280 // cron run double-charge the same subscription.
2281 $lock_key = 'bp_wc_sub_renew_' . $parent->get_id();
2282
2283 if ( get_transient( $lock_key ) ) {
2284 return;
2285 }
2286
2287 set_transient( $lock_key, 1, 10 * MINUTE_IN_SECONDS );
2288
2289 // Manual renewal policy — the site-wide Renewal Mode / Automatic
2290 // Stripe Charging settings or the customer's own auto-renew opt-out:
2291 // invoice the renewal instead of charging it.
2292 if ( ! self::auto_renewal_enabled_for( $parent ) ) {
2293 self::request_manual_renewal( $parent );
2294 return;
2295 }
2296
2297 $customer = (string) $parent->get_meta( self::CUSTOMER_META );
2298 $payment_method = (string) $parent->get_meta( self::PAYMENT_METHOD_META );
2299
2300 if ( '' === $customer || '' === $payment_method ) {
2301 // No stored card to charge — invoice the renewal for manual
2302 // payment instead of stalling the subscription in past_due with
2303 // nothing for the customer to pay. (This is also how a
2304 // subscription sold while renewals were manual keeps renewing
2305 // after the site switches back to automatic.)
2306 self::request_manual_renewal( $parent );
2307 return;
2308 }
2309
2310 // An invoice may already be outstanding — the renewal was invoiced
2311 // while renewals were manual (site policy, the customer's own
2312 // opt-out, or no stored card) and automatic charging has since come
2313 // back. Charge THAT order instead of creating a second one: the
2314 // customer holds a pay link for it, and two live orders for one
2315 // billing period is how a subscription gets paid twice.
2316 $renewal = self::pending_renewal_order( $parent );
2317
2318 if ( $renewal instanceof \WC_Order ) {
2319 $renewal->add_order_note( __( 'Better Payment: automatic renewal resumed — this invoiced renewal is being charged instead of a new order being created.', 'better-payment' ) );
2320 $renewal->save();
2321 } else {
2322 $renewal = self::create_renewal_order( $parent );
2323 }
2324
2325 if ( ! $renewal instanceof \WC_Order ) {
2326 OrderHandler::log( 'Could not create a renewal order for subscription order #' . $parent->get_id() . '.', 'error' );
2327 return;
2328 }
2329
2330 // A $0 renewal — the product is on a 100% sale, or priced free.
2331 // create_renewal_order() re-adds the parent's products at their
2332 // CURRENT price, so this is reachable on any subscription whose price
2333 // has since been discounted to nothing. Stripe rejects a
2334 // PaymentIntent of amount 0, so charging it would fail, call
2335 // mark_past_due() and invoice the customer $0 — turning a healthy
2336 // subscription into a broken one over a discount the site owner
2337 // chose. Complete it directly instead, exactly as
2338 // Gateway::process_payment() completes a $0 FIRST payment, and let
2339 // the shared completion contract advance the schedule.
2340 if ( ! self::renewal_requires_charge( (float) $renewal->get_total() ) ) {
2341 $renewal->add_order_note( __( 'Better Payment: subscription renewal total is zero — nothing to charge. The renewal was completed without contacting Stripe.', 'better-payment' ) );
2342 $renewal->payment_complete();
2343 $renewal->save();
2344
2345 OrderHandler::log( 'Renewal order #' . $renewal->get_id() . ' for subscription order #' . $parent->get_id() . ' completed without a charge (zero total).' );
2346
2347 // No Better Payment transaction row is written: nothing was paid
2348 // and there is no Stripe object to record. Same precedent as the
2349 // $0 first payment under a manual renewal policy
2350 // (Gateway::process_payment()).
2351 do_action( 'better_payment/woocommerce/payment_complete', $renewal, null );
2352 return;
2353 }
2354
2355 $keys = StripeService::get_global_keys();
2356 $bp_order_id = \Better_Payment\Lite\Classes\Handler::new_order_id( 'stripe' );
2357
2358 $data = array(
2359 'bp_order_id' => $bp_order_id,
2360 'wc_order_id' => $renewal->get_id(),
2361 'parent_order_id' => $parent->get_id(),
2362 'order_number' => $renewal->get_order_number(),
2363 'amount' => (float) $renewal->get_total(),
2364 'currency' => $renewal->get_currency(),
2365 'customer' => $customer,
2366 'payment_method' => $payment_method,
2367 'customer_email' => $renewal->get_billing_email(),
2368 'customer_name' => trim( $renewal->get_billing_first_name() . ' ' . $renewal->get_billing_last_name() ),
2369 'site_name' => get_bloginfo( 'name' ),
2370 );
2371
2372 $intent = StripeService::create_payment_intent( self::build_renewal_intent_request( $data ), $keys['secret_key'] );
2373
2374 if ( is_wp_error( $intent ) || empty( $intent->status ) || 'succeeded' !== $intent->status ) {
2375 $reason = is_wp_error( $intent )
2376 ? $intent->get_error_message()
2377 : sprintf(
2378 /* translators: %s: Stripe PaymentIntent status */
2379 __( 'the charge did not succeed (status: %s)', 'better-payment' ),
2380 ! empty( $intent->status ) ? sanitize_text_field( $intent->status ) : 'unknown'
2381 );
2382
2383 // A mis-configured Stripe account is not a failed payment. When
2384 // the keys are swapped (test ↔ live, or a new account), every
2385 // stored customer and payment method belongs to the other
2386 // account, so EVERY subscription would fail at once — each one
2387 // set past_due and each customer emailed an invoice for a card
2388 // that was working yesterday. Hold the subscription instead: no
2389 // status change, no customer email, an order note and a log line
2390 // for the shop owner, and the next cron run retries.
2391 if ( is_wp_error( $intent ) && self::renewal_failure_is_configuration( $intent->get_error_data() ) ) {
2392 self::hold_for_configuration( $parent, $renewal, $reason );
2393 return;
2394 }
2395
2396 self::mark_past_due( $parent, $renewal, $reason );
2397
2398 /**
2399 * Fires after an automatic subscription renewal charge fails.
2400 *
2401 * @since 2.4.0
2402 *
2403 * @param \WC_Order $parent The parent (subscription) order.
2404 * @param \WC_Order $renewal The pending renewal order.
2405 * @param string $reason Failure reason.
2406 */
2407 do_action( 'better_payment/woocommerce/subscription_renewal_failed', $parent, $renewal, $reason );
2408 return;
2409 }
2410
2411 // Record the payment through the engine's own writer, then mark the
2412 // renewal order paid.
2413 $transaction_row_id = Handler::payment_create( self::build_renewal_transaction_data( $data, $intent ) );
2414
2415 $renewal->update_meta_data( '_bp_transaction_id', (string) $transaction_row_id );
2416 $renewal->update_meta_data( '_bp_payment_id', $bp_order_id );
2417 $renewal->update_meta_data( '_bp_gateway', 'stripe' );
2418 $renewal->update_meta_data( '_bp_payment_status', sanitize_text_field( $intent->status ) );
2419 $renewal->update_meta_data( '_bp_paid_at', current_time( 'mysql' ) );
2420 $renewal->add_order_note(
2421 sprintf(
2422 /* translators: 1: Stripe PaymentIntent id, 2: Better Payment record id */
2423 __( 'Better Payment: subscription renewal charged off-session. Transaction ID: %1$s (Better Payment record #%2$d).', 'better-payment' ),
2424 sanitize_text_field( $intent->id ),
2425 (int) $transaction_row_id
2426 )
2427 );
2428 $renewal->payment_complete( sanitize_text_field( $intent->id ) );
2429 $renewal->save();
2430
2431 OrderHandler::log( 'Renewal order #' . $renewal->get_id() . ' charged for subscription order #' . $parent->get_id() . ' (' . $bp_order_id . ').' );
2432
2433 $row = $transaction_row_id ? DB::get_transaction( (int) $transaction_row_id ) : null;
2434
2435 // Same completion contract as a verified first payment — this is what
2436 // advances the parent's schedule (see on_order_paid()).
2437 do_action( 'better_payment/woocommerce/payment_complete', $renewal, $row );
2438 }
2439
2440 /**
2441 * Create the pending renewal order for a subscription: the parent's
2442 * subscription line items at their CURRENT product price, billed to the
2443 * parent's billing address through this gateway.
2444 *
2445 * @param \WC_Order $parent Parent (subscription) order.
2446 * @return \WC_Order|null
2447 */
2448 public static function create_renewal_order( $parent ) {
2449 if ( ! function_exists( 'wc_create_order' ) ) {
2450 return null;
2451 }
2452
2453 $items = self::order_subscription_items( $parent );
2454
2455 if ( empty( $items ) ) {
2456 return null;
2457 }
2458
2459 $renewal = wc_create_order(
2460 array(
2461 'customer_id' => $parent->get_customer_id(),
2462 'status' => 'wc-pending',
2463 'created_via' => 'better_payment_subscription',
2464 )
2465 );
2466
2467 if ( is_wp_error( $renewal ) ) {
2468 return null;
2469 }
2470
2471 $added = 0;
2472 $snapshot = $parent->get_meta( self::PRICE_SNAPSHOT_META );
2473 $seen = array();
2474
2475 foreach ( $items as $item ) {
2476 $product = $item->get_product();
2477
2478 if ( ! $product ) {
2479 continue;
2480 }
2481
2482 $product_id = (int) $product->get_id();
2483 $occurrence = isset( $seen[ $product_id ] ) ? $seen[ $product_id ] : 0;
2484
2485 $seen[ $product_id ] = $occurrence + 1;
2486
2487 // The agreed price, when the subscription has one. Without a
2488 // snapshot — a subscription already running when this shipped —
2489 // add_product() prices the line from the product, which is the
2490 // behavior those subscriptions have always had.
2491 $amounts = self::renewal_line_amounts( $snapshot, $product_id, $occurrence );
2492
2493 $renewal->add_product(
2494 $product,
2495 $item->get_quantity(),
2496 null === $amounts ? array() : array(
2497 'subtotal' => $amounts['subtotal'],
2498 'total' => $amounts['total'],
2499 )
2500 );
2501 $added++;
2502 }
2503
2504 if ( 0 === $added ) {
2505 $renewal->delete( true );
2506 return null;
2507 }
2508
2509 $renewal->set_address( $parent->get_address( 'billing' ), 'billing' );
2510 $renewal->set_payment_method( Gateway::GATEWAY_ID );
2511 $renewal->set_payment_method_title( $parent->get_payment_method_title() );
2512 $renewal->update_meta_data( self::RENEWAL_PARENT_META, (string) $parent->get_id() );
2513 $renewal->update_meta_data( self::CUSTOMER_META, (string) $parent->get_meta( self::CUSTOMER_META ) );
2514 $renewal->update_meta_data( self::PAYMENT_METHOD_META, (string) $parent->get_meta( self::PAYMENT_METHOD_META ) );
2515 $renewal->add_order_note(
2516 sprintf(
2517 /* translators: %d: parent order id */
2518 __( 'Better Payment: subscription renewal order for order #%d.', 'better-payment' ),
2519 $parent->get_id()
2520 )
2521 );
2522 $renewal->calculate_totals();
2523 $renewal->save();
2524
2525 // Relation table: renewal order -> its parent subscription.
2526 self::record_order_relation( $parent->get_id(), $renewal, SubscriptionRelationModel::TYPE_RENEW );
2527
2528 /**
2529 * Fires after a subscription renewal order is created (before it is
2530 * charged).
2531 *
2532 * @since 2.4.0
2533 *
2534 * @param \WC_Order $renewal The pending renewal order.
2535 * @param \WC_Order $parent The parent (subscription) order.
2536 */
2537 do_action( 'better_payment/woocommerce/subscription_renewal_order_created', $renewal, $parent );
2538
2539 return $renewal;
2540 }
2541
2542 /**
2543 * Pure: whether a failed renewal charge is the SITE's fault rather than
2544 * the customer's — the Stripe keys were changed, the account no longer
2545 * holds this customer, the request was rejected outright.
2546 *
2547 * Stripe's own taxonomy does the sorting: `card_error` is the customer
2548 * (declined, expired, insufficient funds), and everything else is the
2549 * integration — `authentication_error` (bad key), `invalid_request_error`
2550 * with `resource_missing` (the customer/payment method belongs to
2551 * another account), `api_error`, `rate_limit_error`.
2552 *
2553 * @param mixed $data WP_Error data from StripeService (`type`, `code`, …).
2554 * @return bool
2555 */
2556 public static function renewal_failure_is_configuration( $data ) {
2557 if ( ! is_array( $data ) ) {
2558 return false;
2559 }
2560
2561 $type = isset( $data['type'] ) ? (string) $data['type'] : '';
2562 $code = isset( $data['code'] ) ? (string) $data['code'] : '';
2563
2564 if ( 'card_error' === $type ) {
2565 return false;
2566 }
2567
2568 if ( in_array( $type, array( 'authentication_error', 'api_error', 'rate_limit_error', 'idempotency_error' ), true ) ) {
2569 return true;
2570 }
2571
2572 return 'invalid_request_error' === $type
2573 && in_array( $code, array( 'resource_missing', 'account_invalid', 'api_key_expired' ), true );
2574 }
2575
2576 /**
2577 * A renewal failed for a reason the customer cannot fix. Hold the
2578 * subscription exactly where it is — active, due, retried on the next
2579 * cron run — and tell the shop owner. Nothing is emailed to the
2580 * customer, and the status is deliberately NOT past_due: a key swap
2581 * would otherwise flip every subscription on the site at once.
2582 *
2583 * @param \WC_Order $parent Parent (subscription) order.
2584 * @param \WC_Order|null $renewal The pending renewal order.
2585 * @param string $reason Failure reason for the order note/log.
2586 * @return void
2587 */
2588 public static function hold_for_configuration( $parent, $renewal, $reason ) {
2589 $parent->add_order_note(
2590 sprintf(
2591 /* translators: %s: failure reason */
2592 __( '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' ),
2593 $reason
2594 )
2595 );
2596
2597 if ( $renewal instanceof \WC_Order ) {
2598 self::remember_pending_renewal( $parent, $renewal );
2599 }
2600
2601 $parent->save();
2602
2603 OrderHandler::log( 'Subscription order #' . $parent->get_id() . ' renewal held — Stripe configuration problem: ' . $reason, 'error' );
2604
2605 /**
2606 * Fires when a renewal is held because Stripe is mis-configured.
2607 *
2608 * @since 2.4.0
2609 *
2610 * @param \WC_Order $parent The parent (subscription) order.
2611 * @param \WC_Order|null $renewal The pending renewal order.
2612 * @param string $reason Failure reason.
2613 */
2614 do_action( 'better_payment/woocommerce/subscription_renewal_held', $parent, $renewal, $reason );
2615 }
2616
2617 /**
2618 * Remember the renewal order that is awaiting payment, so the next cron
2619 * run charges (or re-invoices) THAT order instead of stacking a second
2620 * one beside it. Sets the meta on the parent without saving — callers
2621 * save as part of their own update.
2622 *
2623 * @param mixed $parent Parent (subscription) order.
2624 * @param mixed $renewal Pending renewal order.
2625 * @return void
2626 */
2627 public static function remember_pending_renewal( $parent, $renewal ) {
2628 if ( ! $parent instanceof \WC_Order || ! $renewal instanceof \WC_Order ) {
2629 return;
2630 }
2631
2632 $parent->update_meta_data( self::PENDING_RENEWAL_META, (string) $renewal->get_id() );
2633 }
2634
2635 /**
2636 * Cancel the subscription's outstanding renewal invoice, if it has one
2637 * and it is still unpaid, and forget it. The parent's own meta change is
2638 * left for the caller to save (both callers are mid-update).
2639 *
2640 * An unpaid renewal order keeps a working pay link in the customer's
2641 * inbox, so it has to be closed whenever the schedule it belongs to
2642 * stops applying — a cancellation, or a reactivation that rewrote the
2643 * due date. A renewal that was already PAID is never touched.
2644 *
2645 * @param mixed $parent Parent (subscription) order.
2646 * @param string $note Order note for the cancelled renewal.
2647 * @return void
2648 */
2649 public static function close_pending_renewal( $parent, $note ) {
2650 if ( ! $parent instanceof \WC_Order ) {
2651 return;
2652 }
2653
2654 $pending = self::pending_renewal_order( $parent );
2655
2656 if ( $pending instanceof \WC_Order ) {
2657 $pending->update_status( 'cancelled', (string) $note );
2658 }
2659
2660 $parent->delete_meta_data( self::PENDING_RENEWAL_META );
2661 }
2662
2663 /**
2664 * A renewal could not be charged: stop auto-charging (past_due) and, when
2665 * a pending renewal order exists, email it to the customer to pay
2666 * manually. Paying it reactivates the subscription (settle_renewal()).
2667 *
2668 * @param \WC_Order $parent Parent (subscription) order.
2669 * @param \WC_Order|null $renewal Pending renewal order, when one was created.
2670 * @param string $reason Failure reason for the order note/log.
2671 * @return void
2672 */
2673 public static function mark_past_due( $parent, $renewal, $reason ) {
2674 $parent->update_meta_data( self::STATUS_META, 'past_due' );
2675
2676 // The invoice the customer is about to be emailed is the outstanding
2677 // one: a later policy change must charge it, not open a second.
2678 if ( $renewal instanceof \WC_Order ) {
2679 self::remember_pending_renewal( $parent, $renewal );
2680 }
2681
2682 $parent->add_order_note(
2683 sprintf(
2684 /* translators: %s: failure reason */
2685 __( 'Better Payment: automatic renewal failed — %s. Automatic charging is paused until the pending renewal order is paid.', 'better-payment' ),
2686 $reason
2687 )
2688 );
2689 $parent->save();
2690
2691 OrderHandler::log( 'Subscription order #' . $parent->get_id() . ' set to past_due: ' . $reason, 'error' );
2692
2693 if ( $renewal instanceof \WC_Order ) {
2694 $renewal->add_order_note(
2695 sprintf(
2696 /* translators: %s: failure reason */
2697 __( 'Better Payment: the automatic charge failed — %s. The order was invoiced to the customer for manual payment.', 'better-payment' ),
2698 $reason
2699 )
2700 );
2701 $renewal->save();
2702
2703 self::send_customer_invoice( $renewal );
2704 }
2705 }
2706
2707 /**
2708 * A renewal is due but must be paid manually — because of the site-wide
2709 * manual renewal policy, the customer's auto-renew opt-out, or a missing
2710 * stored payment method. Creates the pending renewal order once and
2711 * emails it to the customer; paying it advances the schedule through
2712 * settle_renewal() exactly like an auto-charged renewal.
2713 *
2714 * Hourly-cron safe: while the previously invoiced renewal order is still
2715 * awaiting payment, nothing new is created or sent.
2716 *
2717 * @param \WC_Order $parent Parent (subscription) order.
2718 * @return void
2719 */
2720 public static function request_manual_renewal( $parent ) {
2721 $pending_id = (int) $parent->get_meta( self::PENDING_RENEWAL_META );
2722
2723 if ( $pending_id > 0 && function_exists( 'wc_get_order' ) ) {
2724 $pending = wc_get_order( $pending_id );
2725
2726 if ( $pending instanceof \WC_Order && $pending->needs_payment() ) {
2727 return; // Already invoiced — waiting on the customer.
2728 }
2729 }
2730
2731 $renewal = self::create_renewal_order( $parent );
2732
2733 if ( ! $renewal instanceof \WC_Order ) {
2734 OrderHandler::log( 'Could not create a manual renewal order for subscription order #' . $parent->get_id() . '.', 'error' );
2735 return;
2736 }
2737
2738 $renewal->add_order_note( __( 'Better Payment: manual subscription renewal — invoiced to the customer for payment.', 'better-payment' ) );
2739 $renewal->save();
2740
2741 $parent->update_meta_data( self::PENDING_RENEWAL_META, (string) $renewal->get_id() );
2742 $parent->add_order_note(
2743 sprintf(
2744 /* translators: %d: renewal order id */
2745 __( 'Better Payment: renewal due — renewal order #%d was invoiced to the customer for manual payment.', 'better-payment' ),
2746 $renewal->get_id()
2747 )
2748 );
2749 $parent->save();
2750
2751 self::send_customer_invoice( $renewal );
2752
2753 OrderHandler::log( 'Manual renewal order #' . $renewal->get_id() . ' invoiced for subscription order #' . $parent->get_id() . '.' );
2754
2755 /**
2756 * Fires after a manual renewal order is created and invoiced.
2757 *
2758 * @since 2.4.0
2759 *
2760 * @param \WC_Order $parent The parent (subscription) order.
2761 * @param \WC_Order $renewal The pending renewal order.
2762 */
2763 do_action( 'better_payment/woocommerce/subscription_manual_renewal_requested', $parent, $renewal );
2764 }
2765
2766 /**
2767 * Email a renewal order to its customer through WooCommerce's own
2768 * customer-invoice email (it carries the pay link).
2769 *
2770 * @param \WC_Order $renewal Renewal order.
2771 * @return void
2772 */
2773 public static function send_customer_invoice( $renewal ) {
2774 if ( ! function_exists( 'WC' ) ) {
2775 return;
2776 }
2777
2778 $emails = WC()->mailer()->get_emails();
2779 $invoice = isset( $emails['WC_Email_Customer_Invoice'] ) ? $emails['WC_Email_Customer_Invoice'] : null;
2780
2781 if ( $invoice instanceof \WC_Email_Customer_Invoice ) {
2782 $invoice->trigger( $renewal->get_id(), $renewal );
2783 }
2784 }
2785
2786 /* ---------------------------------------------------------------------
2787 * Customer auto-renewal toggle (My Account)
2788 * ------------------------------------------------------------------- */
2789
2790 /**
2791 * Automatic-renewal on/off control, rendered on the My Account
2792 * single-subscription view only ({@see MyAccount::render_view()}, context
2793 * 'subscription'), where the form carries a return flag so the handler
2794 * redirects back to the subscription page. It is deliberately not hooked
2795 * onto any order-details screen — see register().
2796 *
2797 * Rendered only when the Customer Auto-Renew Control setting allows it,
2798 * automatic renewal is enabled site-wide (otherwise every renewal is
2799 * manual and the toggle would do nothing), a reusable payment method is
2800 * stored, the subscription is live, and the viewer owns the order.
2801 *
2802 * @param mixed $order The order being viewed.
2803 * @param string $context 'subscription' (the only caller); '' omits the
2804 * return flag, redirecting to the order page.
2805 * @return void
2806 */
2807 public static function render_auto_renew_toggle( $order, $context = '' ) {
2808 if ( ! $order instanceof \WC_Order ) {
2809 return;
2810 }
2811
2812 if ( ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2813 return;
2814 }
2815
2816 if ( 'yes' !== self::setting( 'auto_renewal_toggle' ) || ! self::should_save_payment_method() ) {
2817 return;
2818 }
2819
2820 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2821 return;
2822 }
2823
2824 if ( '' === (string) $order->get_meta( self::CUSTOMER_META ) || '' === (string) $order->get_meta( self::PAYMENT_METHOD_META ) ) {
2825 return;
2826 }
2827
2828 $auto_on = 'no' !== (string) $order->get_meta( self::AUTO_RENEW_META );
2829
2830 ?>
2831 <form method="post" class="bp-subscription-auto-renew">
2832 <?php wp_nonce_field( 'bp_auto_renew_' . $order->get_id(), 'bp_auto_renew_nonce' ); ?>
2833 <input type="hidden" name="bp_auto_renew_order" value="<?php echo esc_attr( (string) $order->get_id() ); ?>">
2834 <input type="hidden" name="bp_auto_renew_value" value="<?php echo esc_attr( $auto_on ? 'no' : 'yes' ); ?>">
2835 <?php if ( 'subscription' === $context ) : ?>
2836 <input type="hidden" name="bp_return_subscription" value="1">
2837 <?php endif; ?>
2838 <p>
2839 <?php
2840 echo esc_html(
2841 $auto_on
2842 ? __( 'Automatic renewal is on — renewals are charged to your saved payment method.', 'better-payment' )
2843 : __( 'Automatic renewal is off — you will be emailed an invoice when each renewal is due.', 'better-payment' )
2844 );
2845 ?>
2846 </p>
2847 <button type="submit" class="button">
2848 <?php echo esc_html( $auto_on ? __( 'Turn off automatic renewal', 'better-payment' ) : __( 'Turn on automatic renewal', 'better-payment' ) ); ?>
2849 </button>
2850 </form>
2851 <?php
2852 }
2853
2854 /**
2855 * Handle the auto-renewal toggle submit. Nonce-guarded and restricted to
2856 * the order's own customer on a live subscription.
2857 *
2858 * @return void
2859 */
2860 public static function maybe_handle_auto_renew_toggle() {
2861 if ( empty( $_POST['bp_auto_renew_order'] ) || ! function_exists( 'wc_get_order' ) ) {
2862 return;
2863 }
2864
2865 $order_id = absint( wp_unslash( $_POST['bp_auto_renew_order'] ) );
2866 $nonce = isset( $_POST['bp_auto_renew_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bp_auto_renew_nonce'] ) ) : '';
2867
2868 if ( ! $order_id || ! wp_verify_nonce( $nonce, 'bp_auto_renew_' . $order_id ) ) {
2869 return;
2870 }
2871
2872 $order = wc_get_order( $order_id );
2873
2874 if ( ! $order || ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2875 return;
2876 }
2877
2878 if ( 'yes' !== self::setting( 'auto_renewal_toggle' ) ) {
2879 return;
2880 }
2881
2882 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2883 return;
2884 }
2885
2886 $enable = isset( $_POST['bp_auto_renew_value'] ) && 'yes' === sanitize_text_field( wp_unslash( $_POST['bp_auto_renew_value'] ) );
2887
2888 $order->update_meta_data( self::AUTO_RENEW_META, $enable ? 'yes' : 'no' );
2889 $order->add_order_note(
2890 $enable
2891 ? __( 'Better Payment: the customer turned automatic renewal ON — due renewals are charged to the saved payment method.', 'better-payment' )
2892 : __( 'Better Payment: the customer turned automatic renewal OFF — due renewals are invoiced for manual payment.', 'better-payment' )
2893 );
2894 $order->save();
2895
2896 OrderHandler::log( 'Customer set auto renewal ' . ( $enable ? 'on' : 'off' ) . ' for subscription order #' . $order->get_id() . '.' );
2897
2898 if ( function_exists( 'wc_add_notice' ) ) {
2899 wc_add_notice(
2900 $enable
2901 ? __( 'Automatic renewal is now on.', 'better-payment' )
2902 : __( 'Automatic renewal is now off. We will email you an invoice when a renewal is due.', 'better-payment' )
2903 );
2904 }
2905
2906 wp_safe_redirect( self::customer_action_redirect( $order ) );
2907 exit;
2908 }
2909
2910 /**
2911 * Where a customer-action handler sends the customer afterwards: back to
2912 * the My Account subscription view when the form came from there (the
2913 * `bp_return_subscription` flag — a flag, never a client-supplied URL),
2914 * otherwise the view-order page the shared forms have always used.
2915 * Only read after the action's nonce has been verified.
2916 *
2917 * @param \WC_Order $order Parent (subscription) order.
2918 * @return string
2919 */
2920 protected static function customer_action_redirect( $order ) {
2921 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- callers verify their own per-order nonce before this runs.
2922 if ( ! empty( $_POST['bp_return_subscription'] ) ) {
2923 $url = MyAccount::view_url( $order->get_id() );
2924
2925 if ( '' !== $url ) {
2926 return $url;
2927 }
2928 }
2929
2930 return $order->get_view_order_url();
2931 }
2932
2933 /* ---------------------------------------------------------------------
2934 * Cancellation
2935 * ------------------------------------------------------------------- */
2936
2937 /**
2938 * Offer the cancel action on parent orders with a live subscription.
2939 *
2940 * @param array $actions Order actions.
2941 * @param mixed $order The order being edited (newer WooCommerce passes it).
2942 * @return array
2943 */
2944 public static function register_order_action( $actions, $order = null ) {
2945 if ( ! $order instanceof \WC_Order && isset( $GLOBALS['theorder'] ) && $GLOBALS['theorder'] instanceof \WC_Order ) {
2946 $order = $GLOBALS['theorder'];
2947 }
2948
2949 if ( $order instanceof \WC_Order && in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2950 $actions['bp_cancel_subscription'] = __( 'Cancel Better Payment Subscription', 'better-payment' );
2951 }
2952
2953 return $actions;
2954 }
2955
2956 /**
2957 * Handle the admin order action.
2958 *
2959 * @param \WC_Order $order Parent (subscription) order.
2960 * @return void
2961 */
2962 public static function handle_cancel_action( $order ) {
2963 self::cancel( $order, '', 'admin' );
2964 }
2965
2966 /**
2967 * Customer-facing cancel button, rendered on the My Account
2968 * single-subscription view only ({@see MyAccount::render_view()}, context
2969 * 'subscription'), where the form carries a return flag so the handler
2970 * redirects back to the subscription page. It is deliberately not hooked
2971 * onto any order-details screen — see register().
2972 *
2973 * Rendered only when the product allowed user cancellation (snapshotted
2974 * on the parent order at activation), the subscription is live, and the
2975 * viewer is the order's own customer.
2976 *
2977 * @param mixed $order The order being viewed.
2978 * @param string $context 'subscription' (the only caller); '' omits the
2979 * return flag, redirecting to the order page.
2980 * @return void
2981 */
2982 public static function render_customer_cancel_button( $order, $context = '' ) {
2983 if ( ! $order instanceof \WC_Order ) {
2984 return;
2985 }
2986
2987 if ( ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
2988 return;
2989 }
2990
2991 if ( 'yes' !== (string) $order->get_meta( self::USER_CANCEL_META ) ) {
2992 return;
2993 }
2994
2995 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
2996 return;
2997 }
2998
2999 ?>
3000 <form method="post" class="bp-subscription-cancel">
3001 <?php wp_nonce_field( 'bp_cancel_subscription_' . $order->get_id(), 'bp_cancel_subscription_nonce' ); ?>
3002 <input type="hidden" name="bp_cancel_subscription_order" value="<?php echo esc_attr( (string) $order->get_id() ); ?>">
3003 <?php if ( 'subscription' === $context ) : ?>
3004 <input type="hidden" name="bp_return_subscription" value="1">
3005 <?php endif; ?>
3006 <button type="submit" class="button" onclick="return confirm( '<?php echo esc_js( __( 'Cancel this subscription? No further renewals will be charged.', 'better-payment' ) ); ?>' );">
3007 <?php esc_html_e( 'Cancel subscription', 'better-payment' ); ?>
3008 </button>
3009 </form>
3010 <?php
3011 }
3012
3013 /**
3014 * Handle the customer cancellation submit. Nonce-guarded and restricted
3015 * to the order's own customer on an order whose product allowed it.
3016 *
3017 * @return void
3018 */
3019 public static function maybe_handle_customer_cancel() {
3020 if ( empty( $_POST['bp_cancel_subscription_order'] ) || ! function_exists( 'wc_get_order' ) ) {
3021 return;
3022 }
3023
3024 $order_id = absint( wp_unslash( $_POST['bp_cancel_subscription_order'] ) );
3025 $nonce = isset( $_POST['bp_cancel_subscription_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bp_cancel_subscription_nonce'] ) ) : '';
3026
3027 if ( ! $order_id || ! wp_verify_nonce( $nonce, 'bp_cancel_subscription_' . $order_id ) ) {
3028 return;
3029 }
3030
3031 $order = wc_get_order( $order_id );
3032
3033 if ( ! $order || ! is_user_logged_in() || get_current_user_id() !== $order->get_customer_id() ) {
3034 return;
3035 }
3036
3037 if ( 'yes' !== (string) $order->get_meta( self::USER_CANCEL_META ) ) {
3038 return;
3039 }
3040
3041 self::cancel( $order, __( 'Better Payment: subscription cancelled by the customer — no further automatic renewals will be charged.', 'better-payment' ), 'customer' );
3042
3043 if ( function_exists( 'wc_add_notice' ) ) {
3044 wc_add_notice( __( 'Your subscription has been cancelled.', 'better-payment' ) );
3045 }
3046
3047 wp_safe_redirect( self::customer_action_redirect( $order ) );
3048 exit;
3049 }
3050
3051 /**
3052 * Cancel a subscription. Terminal: renewals stop; already-created pending
3053 * renewal orders are left for the shop owner to keep or cancel.
3054 *
3055 * @param mixed $order Parent (subscription) order.
3056 * @param string $note Optional order-note override (e.g. the customer-
3057 * initiated cancellation message).
3058 * @param string $actor Who is cancelling: 'customer' | 'admin' | ''
3059 * (unknown — no actor stamp, the details view shows
3060 * no Cancelled By row).
3061 * @return void
3062 */
3063 public static function cancel( $order, $note = '', $actor = '' ) {
3064 if ( ! $order instanceof \WC_Order ) {
3065 return;
3066 }
3067
3068 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'active', 'past_due' ), true ) ) {
3069 return;
3070 }
3071
3072 if ( in_array( $actor, array( 'customer', 'admin' ), true ) ) {
3073 $order->update_meta_data( self::CANCELLED_BY_TYPE_META, $actor );
3074 $order->update_meta_data( self::CANCELLED_BY_META, (string) get_current_user_id() );
3075 }
3076
3077 $order->update_meta_data( self::STATUS_META, 'cancelled' );
3078 $order->add_order_note( '' !== $note ? $note : __( 'Better Payment: subscription cancelled — no further automatic renewals will be charged.', 'better-payment' ) );
3079
3080 // An invoiced renewal is an open bill with a working pay link. Left
3081 // alone it outlives the subscription: the customer pays for a period
3082 // they cancelled, and settle_renewal() refuses to advance a
3083 // cancelled schedule — so the money buys nothing.
3084 self::close_pending_renewal( $order, __( 'Better Payment: cancelled with the subscription — this renewal is no longer due.', 'better-payment' ) );
3085
3086 $order->save();
3087
3088 OrderHandler::log( 'Subscription cancelled on order #' . $order->get_id() . '.' );
3089
3090 /**
3091 * Fires after a Better Payment subscription is cancelled.
3092 *
3093 * @since 2.4.0
3094 *
3095 * @param \WC_Order $order The parent (subscription) order.
3096 */
3097 do_action( 'better_payment/woocommerce/subscription_cancelled', $order );
3098 }
3099
3100 /**
3101 * Reactivate a cancelled (or past_due) subscription from the admin
3102 * Subscriptions tab. Sets the status back to active; when the stored
3103 * next-payment date already elapsed while the subscription was
3104 * cancelled, a fresh one is scheduled a full cycle from now — otherwise
3105 * the hourly cron would charge the customer the moment reactivation
3106 * saved.
3107 *
3108 * @param mixed $order Parent (subscription) order.
3109 * @param string $note Optional order-note override.
3110 * @return void
3111 */
3112 public static function reactivate( $order, $note = '' ) {
3113 if ( ! $order instanceof \WC_Order ) {
3114 return;
3115 }
3116
3117 if ( ! in_array( (string) $order->get_meta( self::STATUS_META ), array( 'cancelled', 'past_due' ), true ) ) {
3118 return;
3119 }
3120
3121 $next = (int) $order->get_meta( self::NEXT_PAYMENT_META );
3122
3123 if ( $next <= time() ) {
3124 $interval = max( 1, (int) $order->get_meta( self::INTERVAL_META ) );
3125 $period = self::sanitize_period( $order->get_meta( self::PERIOD_META ) );
3126 $next = self::next_payment_timestamp( $interval, $period );
3127 $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next );
3128 }
3129
3130 $order->update_meta_data( self::STATUS_META, 'active' );
3131 // The subscription is live again — a later cancellation must record
3132 // its own actor, never inherit this one's.
3133 $order->delete_meta_data( self::CANCELLED_BY_TYPE_META );
3134 $order->delete_meta_data( self::CANCELLED_BY_META );
3135
3136 // Reactivation just wrote a new due date, so an invoice raised
3137 // against the old one is stale. Leaving it payable means the next
3138 // renewal charge and that invoice can both be paid for one period.
3139 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' ) );
3140 $order->add_order_note(
3141 '' !== $note ? $note : sprintf(
3142 /* translators: %s: next renewal date */
3143 __( 'Better Payment: subscription reactivated. Next renewal: %s.', 'better-payment' ),
3144 date_i18n( get_option( 'date_format' ), $next )
3145 )
3146 );
3147 $order->save();
3148
3149 OrderHandler::log( 'Subscription reactivated on order #' . $order->get_id() . ' (next renewal ' . gmdate( 'Y-m-d H:i:s', $next ) . ' UTC).' );
3150
3151 /**
3152 * Fires after a Better Payment subscription is reactivated.
3153 *
3154 * @since 2.4.0
3155 *
3156 * @param \WC_Order $order The parent (subscription) order.
3157 */
3158 do_action( 'better_payment/woocommerce/subscription_reactivated', $order );
3159 }
3160
3161 /* ---------------------------------------------------------------------
3162 * Subscriber role sync
3163 * ------------------------------------------------------------------- */
3164
3165 /**
3166 * Subscription activated → give the customer the Subscriber Default
3167 * Role.
3168 *
3169 * @param mixed $order Parent (subscription) order.
3170 * @return void
3171 */
3172 public static function assign_active_role( $order ) {
3173 self::swap_customer_role( $order, self::setting( 'active_role' ), true );
3174 }
3175
3176 /**
3177 * Subscription cancelled/completed → give the customer the Subscriber
3178 * Inactive Role, unless they still hold another live subscription.
3179 *
3180 * @param mixed $order Parent (subscription) order.
3181 * @return void
3182 */
3183 public static function assign_inactive_role( $order ) {
3184 if ( ! $order instanceof \WC_Order ) {
3185 return;
3186 }
3187
3188 if ( self::customer_has_other_live_subscription( $order ) ) {
3189 return;
3190 }
3191
3192 self::swap_customer_role( $order, self::setting( 'inactive_role' ), false );
3193 }
3194
3195 /**
3196 * Pure: whether a user's current roles forbid the subscription role
3197 * swap. Site staff must never be demoted to a subscriber role by buying
3198 * or cancelling a subscription — set_role() REPLACES the user's roles.
3199 *
3200 * @param mixed $roles The user's current role slugs.
3201 * @return bool
3202 */
3203 public static function user_role_is_protected( $roles ) {
3204 /**
3205 * Filters the roles the subscription role sync refuses to replace.
3206 *
3207 * @since 2.4.0
3208 *
3209 * @param string[] $protected Protected role slugs.
3210 */
3211 $protected = apply_filters(
3212 'better_payment/woocommerce/role_sync_protected_roles',
3213 array( 'administrator', 'editor', 'shop_manager' )
3214 );
3215
3216 return (bool) array_intersect( (array) $roles, (array) $protected );
3217 }
3218
3219 /**
3220 * Replace the order's customer role for the subscription lifecycle.
3221 * No-ops on guest orders, unknown/blank target roles, protected users,
3222 * and users already holding the target role.
3223 *
3224 * @param mixed $order Parent (subscription) order.
3225 * @param string $role Target role slug (from the settings).
3226 * @param bool $active Whether the swap is for an activation (for the note).
3227 * @return void
3228 */
3229 public static function swap_customer_role( $order, $role, $active ) {
3230 if ( ! $order instanceof \WC_Order ) {
3231 return;
3232 }
3233
3234 $role = (string) $role;
3235
3236 if ( '' === $role || null === get_role( $role ) ) {
3237 return; // Unknown role — never assign a role that doesn't exist.
3238 }
3239
3240 $user_id = (int) $order->get_customer_id();
3241
3242 if ( $user_id <= 0 ) {
3243 return; // Guest order — no account to update.
3244 }
3245
3246 $user = get_user_by( 'id', $user_id );
3247
3248 if ( ! $user || self::user_role_is_protected( $user->roles ) ) {
3249 return;
3250 }
3251
3252 $granted = (string) get_user_meta( $user_id, self::GRANTED_ROLE_META, true );
3253
3254 if ( in_array( $role, (array) $user->roles, true ) ) {
3255 // Already there. Record it as ours only if we put it there — a
3256 // role the customer already had is not this module's to remove.
3257 if ( $granted === $role ) {
3258 return;
3259 }
3260
3261 if ( '' === $granted ) {
3262 return;
3263 }
3264 }
3265
3266 // ADD the subscription role and REMOVE only the one this module
3267 // granted last time. set_role() — what this used to call — replaces
3268 // every role the user has, so activating a subscription wiped a
3269 // membership plugin's role, a custom staff role, or plain `customer`,
3270 // and cancelling it "restored" whatever the settings happened to say
3271 // rather than what the customer actually had. Nothing outside this
3272 // module's own grant is touched now.
3273 if ( '' !== $granted && $granted !== $role && in_array( $granted, (array) $user->roles, true ) ) {
3274 $user->remove_role( $granted );
3275 }
3276
3277 if ( ! in_array( $role, (array) $user->roles, true ) ) {
3278 $user->add_role( $role );
3279 }
3280
3281 update_user_meta( $user_id, self::GRANTED_ROLE_META, $role );
3282
3283 $note_template = $active
3284 /* translators: %s: role slug */
3285 ? __( 'Better Payment: the customer was given the "%s" role — subscription active. Their other roles are unchanged.', 'better-payment' )
3286 /* translators: %s: role slug */
3287 : __( 'Better Payment: the customer was given the "%s" role — subscription ended. Their other roles are unchanged.', 'better-payment' );
3288
3289 $order->add_order_note( sprintf( $note_template, $role ) );
3290 $order->save();
3291
3292 OrderHandler::log( 'Customer #' . $user_id . ' given the ' . $role . ' role for subscription order #' . $order->get_id() . '.' );
3293 }
3294
3295 /**
3296 * Whether the order's customer holds another live (active or past_due)
3297 * subscription besides this one.
3298 *
3299 * @param \WC_Order $order Parent (subscription) order.
3300 * @return bool
3301 */
3302 public static function customer_has_other_live_subscription( $order ) {
3303 if ( ! function_exists( 'wc_get_orders' ) ) {
3304 return false;
3305 }
3306
3307 $customer_id = (int) $order->get_customer_id();
3308
3309 if ( $customer_id <= 0 ) {
3310 return false;
3311 }
3312
3313 $others = wc_get_orders(
3314 array(
3315 'limit' => 1,
3316 'type' => 'shop_order',
3317 'customer_id' => $customer_id,
3318 'exclude' => array( $order->get_id() ),
3319 'return' => 'ids',
3320 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 1), event-driven.
3321 array(
3322 'key' => self::STATUS_META,
3323 'value' => array( 'active', 'past_due' ),
3324 'compare' => 'IN',
3325 ),
3326 ),
3327 )
3328 );
3329
3330 return is_array( $others ) && count( $others ) > 0;
3331 }
3332
3333 /* ---------------------------------------------------------------------
3334 * Admin list (React admin → Subscriptions tab)
3335 * ------------------------------------------------------------------- */
3336
3337 /**
3338 * Flat display row for one subscription on the admin Subscriptions tab.
3339 *
3340 * Reads the parent (subscription) order and its `_bp_subscription_*`
3341 * meta. Safe to call whether or not WooCommerce is active — returns null
3342 * when WooCommerce (or the order) is unavailable, and the caller keeps
3343 * its un-hydrated base row.
3344 *
3345 * Dates are returned as site-local `Y-m-d H:i:s` strings — the same shape
3346 * as the transactions table's `payment_date` — so the React admin formats
3347 * them with the one date helper it already has.
3348 *
3349 * @param int $subscription_id Parent (subscription) order id.
3350 * @return array|null
3351 * @since 2.4.0
3352 */
3353 public static function admin_list_row( $subscription_id ) {
3354 if ( ! function_exists( 'wc_get_order' ) ) {
3355 return null;
3356 }
3357
3358 $order = wc_get_order( absint( $subscription_id ) );
3359
3360 if ( ! $order instanceof \WC_Order ) {
3361 return null;
3362 }
3363
3364 $items = self::order_subscription_items( $order );
3365 $first_item = ! empty( $items ) ? current( $items ) : null;
3366 $product_name = $first_item instanceof \WC_Order_Item_Product ? $first_item->get_name() : '';
3367
3368 $customer_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
3369 if ( '' === $customer_name ) {
3370 $customer_name = trim( (string) $order->get_formatted_billing_full_name() );
3371 }
3372
3373 $next_payment = (int) $order->get_meta( self::NEXT_PAYMENT_META );
3374 $status = (string) $order->get_meta( self::STATUS_META );
3375 $created = $order->get_date_created();
3376
3377 return array(
3378 'customer_name' => $customer_name,
3379 'customer_email' => (string) $order->get_billing_email(),
3380 'product_name' => (string) $product_name,
3381 'amount' => (float) $order->get_total(),
3382 'currency' => (string) $order->get_currency(),
3383 'interval' => max( 1, (int) $order->get_meta( self::INTERVAL_META ) ),
3384 'period' => self::sanitize_period( $order->get_meta( self::PERIOD_META ) ),
3385 'status' => $status,
3386 'renewal_count' => (int) $order->get_meta( self::RENEWAL_COUNT_META ),
3387 // No next charge on a terminal subscription — send '' so the UI
3388 // shows an em dash instead of a stale date.
3389 'next_payment' => ( $next_payment > 0 && in_array( $status, array( 'active', 'past_due' ), true ) )
3390 ? wp_date( 'Y-m-d H:i:s', $next_payment )
3391 : '',
3392 'start_date' => $created ? $created->date( 'Y-m-d H:i:s' ) : '',
3393 'order_edit_url' => (string) $order->get_edit_order_url(),
3394 );
3395 }
3396
3397 /**
3398 * Light status lookup for one subscription — just the raw
3399 * `_bp_subscription_status` meta, lowercased, with no row hydration.
3400 * Used by the admin Subscriptions tab's summary counts, which need
3401 * every subscription's status but nothing else from the order.
3402 *
3403 * @param int $subscription_id Parent (subscription) order id.
3404 * @return string Raw status ('' when WooCommerce or the order is gone).
3405 * @since 2.4.0
3406 */
3407 public static function admin_status( $subscription_id ) {
3408 if ( ! function_exists( 'wc_get_order' ) ) {
3409 return '';
3410 }
3411
3412 $order = wc_get_order( absint( $subscription_id ) );
3413
3414 if ( ! $order instanceof \WC_Order ) {
3415 return '';
3416 }
3417
3418 return strtolower( (string) $order->get_meta( self::STATUS_META ) );
3419 }
3420
3421 /**
3422 * Pure: the status actions the admin Subscriptions details view may
3423 * offer for a subscription in the given status. Status transitions are
3424 * the ONLY thing editable — everything else is read-only.
3425 *
3426 * `completed` is terminal (the subscription's schedule ended;
3427 * reactivating would restart a schedule the customer finished paying),
3428 * and an empty/unknown status (un-hydrated row, integration gone)
3429 * offers nothing.
3430 *
3431 * @param string $status Raw `_bp_subscription_status` meta value.
3432 * @return string[] Zero or more of 'cancel' | 'reactivate'.
3433 */
3434 public static function available_admin_actions( $status ) {
3435 $map = array(
3436 'active' => array( 'cancel' ),
3437 'past_due' => array( 'cancel', 'reactivate' ),
3438 'cancelled' => array( 'reactivate' ),
3439 );
3440
3441 $status = strtolower( (string) $status );
3442
3443 return isset( $map[ $status ] ) ? $map[ $status ] : array();
3444 }
3445
3446 /**
3447 * Extended display data for one subscription on the admin details view:
3448 * the list row plus the fields only the details page shows. Returns null
3449 * when WooCommerce (or the order) is unavailable, same contract as
3450 * admin_list_row().
3451 *
3452 * @param int $subscription_id Parent (subscription) order id.
3453 * @return array|null
3454 * @since 2.4.0
3455 */
3456 public static function admin_detail( $subscription_id ) {
3457 $row = self::admin_list_row( $subscription_id );
3458
3459 if ( null === $row ) {
3460 return null;
3461 }
3462
3463 $order = wc_get_order( absint( $subscription_id ) );
3464
3465 if ( ! $order instanceof \WC_Order ) {
3466 return null;
3467 }
3468
3469 // Effective state, not the raw AUTO_RENEW_META flag: the site-wide
3470 // renewal policy AND the customer's own preference — the same test
3471 // renew() applies. Reporting the flag alone showed "On" while a
3472 // manual policy meant every renewal was actually invoiced.
3473 $row['auto_renew'] = self::auto_renewal_enabled_for( $order ) ? 'yes' : 'no';
3474 $row['available_actions'] = self::available_admin_actions( $row['status'] );
3475
3476 // When the most recent renewal payment settled — '' until the first
3477 // renewal, and the UI drops the row entirely rather than showing an
3478 // empty value (same treatment next_payment gets on a terminal
3479 // subscription).
3480 $last_payment = self::last_payment_timestamp( $order );
3481 $row['last_payment'] = $last_payment > 0 ? wp_date( 'Y-m-d H:i:s', $last_payment ) : '';
3482
3483 // Who cancelled the subscription. Only reported while the status is
3484 // actually cancelled AND cancel() stamped an actor — pre-stamp
3485 // cancellations have no answer, and the UI drops the row rather than
3486 // guessing.
3487 $row['cancelled_by_type'] = '';
3488 $row['cancelled_by_name'] = '';
3489
3490 if ( 'cancelled' === $row['status'] ) {
3491 $actor = (string) $order->get_meta( self::CANCELLED_BY_TYPE_META );
3492
3493 if ( in_array( $actor, array( 'customer', 'admin' ), true ) ) {
3494 $row['cancelled_by_type'] = $actor;
3495 $row['cancelled_by_name'] = self::cancelled_by_name( $order, $actor );
3496 }
3497 }
3498
3499 // Billing & Shipping card. Plain-text lines, not WooCommerce's
3500 // formatted-address HTML — the React admin renders text children
3501 // only, never markup.
3502 $row['billing_address'] = self::address_lines( $order->get_formatted_billing_address() );
3503 $row['shipping_address'] = self::address_lines( $order->get_formatted_shipping_address() );
3504 $row['billing_phone'] = (string) $order->get_billing_phone();
3505
3506 return $row;
3507 }
3508
3509 /**
3510 * Display name of the user who cancelled the subscription. Resolved live
3511 * from the stamped user id so a renamed account reads current; when that
3512 * user is gone (or the stamp predates a user id), a customer
3513 * cancellation still has the order's own billing name to fall back on —
3514 * an admin one does not, and returns '' (the UI shows the actor tag
3515 * alone).
3516 *
3517 * @param \WC_Order $order Parent (subscription) order.
3518 * @param string $actor 'customer' | 'admin'.
3519 * @return string
3520 * @since 2.4.0
3521 */
3522 public static function cancelled_by_name( $order, $actor ) {
3523 $user_id = (int) $order->get_meta( self::CANCELLED_BY_META );
3524 $user = $user_id > 0 ? get_userdata( $user_id ) : false;
3525
3526 if ( $user && '' !== trim( (string) $user->display_name ) ) {
3527 return (string) $user->display_name;
3528 }
3529
3530 if ( 'customer' === $actor ) {
3531 $billing = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
3532
3533 if ( '' !== $billing ) {
3534 return $billing;
3535 }
3536 }
3537
3538 return '';
3539 }
3540
3541 /**
3542 * When the subscription's most recent renewal payment settled, as a UNIX
3543 * timestamp — 0 when it has never renewed (the initial checkout is the
3544 * Started date, not a renewal payment). Reads the LAST_PAYMENT_META stamp
3545 * settle_renewal() writes; subscriptions renewed before the stamp existed
3546 * fall back to the newest paid renewal order's paid date.
3547 *
3548 * @param \WC_Order $order Parent (subscription) order.
3549 * @return int
3550 * @since 2.4.0
3551 */
3552 public static function last_payment_timestamp( $order ) {
3553 $stamped = (int) $order->get_meta( self::LAST_PAYMENT_META );
3554
3555 if ( $stamped > 0 ) {
3556 return $stamped;
3557 }
3558
3559 if ( (int) $order->get_meta( self::RENEWAL_COUNT_META ) < 1 ) {
3560 return 0;
3561 }
3562
3563 $renewals = wc_get_orders(
3564 array(
3565 'limit' => 1,
3566 'type' => 'shop_order',
3567 'status' => array( 'wc-processing', 'wc-completed' ),
3568 'orderby' => 'date',
3569 'order' => 'DESC',
3570 'meta_key' => self::RENEWAL_PARENT_META, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- bounded (limit 1), details-view only.
3571 'meta_value' => (string) $order->get_id(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- see above.
3572 )
3573 );
3574
3575 $renewal = is_array( $renewals ) && ! empty( $renewals ) ? current( $renewals ) : null;
3576
3577 if ( ! $renewal instanceof \WC_Order ) {
3578 return 0;
3579 }
3580
3581 $paid = $renewal->get_date_paid();
3582
3583 if ( ! $paid ) {
3584 $paid = $renewal->get_date_created();
3585 }
3586
3587 return $paid ? $paid->getTimestamp() : 0;
3588 }
3589
3590 /**
3591 * Pure: a WooCommerce formatted address (lines joined with <br/>) as an
3592 * array of plain-text lines. Empty/whitespace lines are dropped; an
3593 * empty/absent address yields an empty array (the UI renders an em dash).
3594 *
3595 * @param mixed $formatted The formatted address HTML ('' when unset).
3596 * @return string[]
3597 * @since 2.4.0
3598 */
3599 public static function address_lines( $formatted ) {
3600 if ( ! is_string( $formatted ) || '' === trim( $formatted ) ) {
3601 return array();
3602 }
3603
3604 $lines = preg_split( '#<br\s*/?>#i', $formatted );
3605 $clean = array();
3606
3607 foreach ( (array) $lines as $line ) {
3608 $line = trim( wp_strip_all_tags( (string) $line ) );
3609
3610 if ( '' !== $line ) {
3611 $clean[] = $line;
3612 }
3613 }
3614
3615 return $clean;
3616 }
3617
3618 /**
3619 * Display row for one related order on the admin details view's
3620 * Related Orders list. Returns null when WooCommerce (or the order) is
3621 * unavailable — the caller keeps its un-hydrated base row.
3622 *
3623 * @param int $order_id Related (parent or renewal) order id.
3624 * @return array|null
3625 * @since 2.4.0
3626 */
3627 public static function admin_order_row( $order_id ) {
3628 if ( ! function_exists( 'wc_get_order' ) ) {
3629 return null;
3630 }
3631
3632 $order = wc_get_order( absint( $order_id ) );
3633
3634 if ( ! $order instanceof \WC_Order ) {
3635 return null;
3636 }
3637
3638 $created = $order->get_date_created();
3639 $status = (string) $order->get_status();
3640
3641 return array(
3642 'order_number' => (string) $order->get_order_number(),
3643 'date' => $created ? $created->date( 'Y-m-d H:i:s' ) : '',
3644 'status' => $status,
3645 'status_label' => function_exists( 'wc_get_order_status_name' ) ? wc_get_order_status_name( $status ) : ucfirst( $status ),
3646 'total' => (float) $order->get_total(),
3647 'currency' => (string) $order->get_currency(),
3648 'edit_url' => (string) $order->get_edit_order_url(),
3649 );
3650 }
3651
3652 /**
3653 * Perform an admin status action (cancel | reactivate) on a
3654 * subscription. Used by the admin Subscriptions REST endpoint.
3655 *
3656 * @param int $subscription_id Parent (subscription) order id.
3657 * @param string $action 'cancel' or 'reactivate'.
3658 * @return true|\WP_Error
3659 * @since 2.4.0
3660 */
3661 public static function admin_status_action( $subscription_id, $action ) {
3662 if ( ! function_exists( 'wc_get_order' ) ) {
3663 return new \WP_Error( 'woocommerce_inactive', __( 'WooCommerce is not active, so this subscription cannot be managed.', 'better-payment' ), array( 'status' => 400 ) );
3664 }
3665
3666 $order = wc_get_order( absint( $subscription_id ) );
3667
3668 if ( ! $order instanceof \WC_Order ) {
3669 return new \WP_Error( 'subscription_not_found', __( 'Subscription order not found.', 'better-payment' ), array( 'status' => 404 ) );
3670 }
3671
3672 $status = (string) $order->get_meta( self::STATUS_META );
3673
3674 if ( ! in_array( $action, self::available_admin_actions( $status ), true ) ) {
3675 return new \WP_Error( 'action_not_available', __( 'This action is not available for the subscription\'s current status.', 'better-payment' ), array( 'status' => 400 ) );
3676 }
3677
3678 if ( 'cancel' === $action ) {
3679 self::cancel( $order, __( 'Better Payment: subscription cancelled from the Better Payment admin — no further automatic renewals will be charged.', 'better-payment' ), 'admin' );
3680 } else {
3681 self::reactivate( $order );
3682 }
3683
3684 return true;
3685 }
3686 }
3687